Skip to content

Domains

The Tomoda backend is organized as vertical domain slices. Each domain is one Go package under backend/internal/services/<domain>/ that owns its full stack: persistence, business logic, HTTP surface, and route mounting. This page is the map: what each domain owns, how a slice is shaped, and how slices talk to each other without importing each other's internals.

For the reasoning behind this shape see Vertical domain slices; for the tables each domain owns see Data Model.

Anatomy of a slice

A domain package is a self-contained feature. The files are conventional:

File Role
store.go Persistence. The only layer that touches the database, running the domain's sqlc-generated queries over pgx. Returns *models.X.
service.go Business logic and orchestration across stores, caches, and other domains' ports.
handler.go HTTP: bind and validate input, call the service, shape the response. Owns its response DTOs.
routes.go Mounts the domain's endpoints onto a passed-in group. Imports no middleware.
wire.go / deps.go Wire provider set for the slice; deps.go declares the ports it needs from other domains.
*_test.go Colocated unit and mock-based tests.

Constructors are New<Thing> (NewEventService, NewEventStore). Types are Store, Service, Handler. Multi-aggregate domains prefix files and types by aggregate: event_service.go / EventService, plan_store.go / PlanStore. The full object graph is composed in backend/internal/wiring, and the route tree is assembled in backend/internal/wiring/router.go.

The domain map

Twenty domains. Most expose an HTTP surface; content, passport, and gameengine are internal (consumed by other slices, no routes of their own).

Domain HTTP prefix Owns
auth /auth, /users/me/avatar, .well-known/webauthn Identity, login, sessions, OTP, WebAuthn, API keys, avatar finalize.
user /users/:userId (lean card), /users/me/* User card (GetUserCard), profile, preferences, denormalized stats.
friend /friends, /location Friendship graph, close friends, friend location sharing.
event /events, /share/events/:id, /users/:userId/events Event lifecycle, participants, event chat send, event-items snapshot, check-in.
plan /plans, /items, /users/:userId/plans Plan canvas, item board (stash + shared), availability poll, collaborators, promotion.
chat /chats, /ws/chats/:id, /ws/client Chat rooms, DMs, group chats, messages, reactions, the two WebSocket hubs.
moment /moments Ephemeral, location-anchored posts, likes, companion tags.
presence /presence, /checkins Heartbeat, opt-in live-location sharing, check-ins with companion tagging.
location /locations/{nearby,nearby/autocomplete,reverse,resolve,countries,countries/:code,:id/report-outdated}, /admin/locations/ranking-weights Place search, geocoding, resolve, dedup, country reference, report-outdated, admin rank-weight apply.
maps /map/location/:id, /map/event/:id, /map/* Map-card detail reads and per-user personalization (saves, dismissals, affinity).
discovery /discovery Read-aggregation: viewport map, radar, search, location detail, pin, taxonomy, aggregated profile card.
media /uploads, /klipy, /link-preview Presigned S3 upload starters, GIF/sticker proxy, SSRF-guarded link preview.
notification /notifications, /push-tokens Inbox rows, grouping, push-token registration.
partner /partners, /me/partners, /partner/:partner_id Multi-tenant partners and membership-scoped access.
payment /payment/webhook, /auth/payment/* Stripe checkout, billing portal, webhook.
admin /admin, /admin/async Admin console: stats, user moderation, location merge, async queue ops.
content (internal) Curated catalog: TomodaStamp, StampGuideEntry, Curio, Challenge.
passport (internal) Earned state: travel log, stamps, curios, progress, first-discoveries.
gameengine (internal) Rule evaluation: reads content, writes passport on qualifying actions.

The taxonomy vocabulary, WebSocket hubs, Redis cache, S3 driver, email, audit, and the semantic LLM resolver are shared infrastructure in backend/internal/platform/* and backend/internal/storage, not domains. See System Overview.

Route composition

A domain's routes.go exposes RegisterRoutes(r, handler, ...gates) (where r is a chi.Router) and never imports the middleware or access packages. Auth gates (JWTAuth, OptionalJWTAuth, rate limiters, access.Require(...)) are constructed in backend/internal/wiring/router.go and passed in as func(http.Handler) http.Handler parameters. This keeps each slice free of a middleware dependency and makes the full gate-to-route mapping auditable in one file.

Public (optional-auth or unauthenticated) endpoints get a separate RegisterPublicRoutes: shareable event pages, the aggregated profile card, map-card reads, and provider webhooks.

Cross-domain ports

A slice never reaches into another slice's store or service. When domain A needs something from domain B, A declares a narrow interface (a port) in its own deps.go, and B's service satisfies it. The edge is one-way and mockable, and the wiring package supplies the concrete implementation.

maps
EventDetailProvider
event
presence
TagDMSender
chat
passport
ProfileDeltas
user
A consumer declares the interface it needs in its own deps.go; the provider implements it. The arrow is the port name, and it points from consumer to provider so import edges stay one-way.

Concrete ports today:

Consumer Port (in its deps.go) Provider What it fetches / does
maps EventDetailProvider event Viewer-projected event detail for the map card.
presence TagDMSender chat Deliver the companion-tag direct message.
event MessagePurger chat Purge event-chat messages during lifecycle cleanup.
passport ProfileDeltas, FirstDiscoveryClaimer user Fold a visit into denormalized profile stats.
user FriendChecker, EventCleaner, AssetDeleter friend, event, media Friendship checks, orphan cleanup, async asset delete.

The MessagePurger case shows why ports matter: chat depends on event's store, so event must not import chat. Instead event declares MessagePurger and chat implements it, keeping the dependency one-way.

Discovery is the read side

discovery is the query and read-aggregation domain. It queries the database directly across events, locations, users, and moments, and reuses taxonomy.ParseQuery, the platform/llm semantic resolver, and location geocoding. It serves the unified /discovery/search intent endpoint, the viewport /discovery/map, and the full aggregated profile card at /discovery/profiles/:id (optional auth).

Write-owning domains stay lean: user serves only the lean card at GET /users/:userId (GetUserCard{id, name, username, avatar_url, bio, is_friend}); the rich, cross-domain profile is a discovery read. See Discovery is the read-aggregation domain.