A guided tour of the TeslaSync repository. Not an exhaustive directory listing β those go stale within a sprint. Instead: what each top-level directory is responsible for, why it's separate from its neighbours, and how to decide where new code goes.
teslasync/
βββ cmd/ # Go entrypoints β one per binary
βββ internal/ # Go code only this repo can import
βββ web/ # React SPA
βββ migrations/ # SQL migrations, applied in order on boot
βββ helm/teslasync/ # Helm chart for Kubernetes deployments
βββ docker-compose.yml # Default container topology
βββ docs/ # VitePress user docs (this site)
βββ tools/ # Code generators + CI vets
βββ grafana/ # Provisioned dashboards + datasources
βββ prometheus/ # Scrape config + alert rules
βββ .github/ # CI workflows, audit skills, prompt library
Everything else is configuration for one of the above (e.g., .env.example, Dockerfile.*).
Two principles drove the structure:
- The platform is bigger than a single service. It's an API, four workers, a SPA, a docs site, a metrics stack, an optional AI layer, and an optional telemetry sidecar. Lumping everything into one directory would have made it impossible to reason about what changes when.
- Internal-only and shared boundaries matter. Go has
internal/for "no one outside this module can import this". The frontend has thecomponents/uiandcomponents/chartsbarrels for "this is the shared design system; don't import the underlying library directly". Both boundaries are enforced by CI, not by convention.
One subdirectory per binary. Each has a main.go that wires together packages from internal/:
| Path | Binary | Responsibility |
|---|---|---|
cmd/teslasync/ |
teslasync |
HTTP API + SSE + scheduled workers |
cmd/notification-worker/ |
notification-worker |
Drains the notification queue, fans out to channels |
cmd/export-worker/ |
export-worker |
Generates CSV/JSON/Parquet exports asynchronously |
cmd/automation-worker/ |
automation-worker |
Schedules + executes automations |
cmd/migrate/ |
migrate |
Runs migrations standalone (CI / one-off ops) |
Rule: cmd/* packages contain wiring only β no business logic. If you find yourself writing real code in a cmd/ package, move it to internal/ and import it.
The bulk of the backend lives here. Each subdirectory is a bounded responsibility:
| Subdirectory | What lives there |
|---|---|
internal/api/ |
HTTP handlers, route registration, middleware |
internal/api/ai_routes.go |
Helix AI route wrapping (g.Wrap("<feature-id>", h)) |
internal/auth/ |
Session, forward-auth header parsing, TOTP, token rotation |
internal/tesla/ |
Tesla Fleet API client, OAuth, the 65 command endpoints |
internal/telemetry/ |
Fleet Telemetry ingest, MQTT bridge, signal normalisation |
internal/signal/ |
L1 in-process signal store + repository |
internal/redis/ |
L2 cache, pub/sub fanout, rate-limit counters |
internal/database/ |
DB connection, migrations, hypertable / continuous aggregate helpers |
internal/repository/ |
Repository pattern per domain (vehicles, drives, alerts, β¦) |
internal/ai/ |
Helix AI β providers, decorators, strategies, tools, registry |
internal/ai/provider/ |
Ollama, OpenAI, Azure, Anthropic, mock |
internal/ai/dispatch/ |
Tool-use loop |
internal/ai/features/ |
The registry (source of truth for AI features) |
internal/automation/ |
Trigger evaluation, action execution, schedule worker |
internal/alerts/ |
Typed rule families, channel routing, throttling |
internal/notifications/ |
Queue + channel adapters (email, SMS, push, webhook, β¦) |
internal/exports/ |
Async export pipeline, format adapters |
internal/observability/ |
OpenTelemetry setup, trace + metric helpers, structured logging |
internal/config/ |
Env-var β typed config |
internal/health/ |
/healthz and /readyz handlers |
Within internal/, layers depend in one direction:
api βββΆ repository βββΆ database
β² β
β βΌ
ai, automation, alerts PostgreSQL
- Handlers (
internal/api/) are thin. They parse inputs, call a repository or a domain package, and render the response. No SQL, no business logic. - Repositories (
internal/repository/) own SQL. They expose typed methods (GetByID,ListForVehicle,Upsert) and return typed structs. No HTTP concerns. - Domain packages (
internal/automation/,internal/alerts/,internal/ai/) implement the actual logic. They call repositories for persistence and providers for I/O. - Infrastructure (
internal/database/,internal/redis/,internal/telemetry/) provides building blocks. They don't know about domain logic.
What NOT to do:
- Don't import
internal/api/from anywhere else (it's the top of the stack) - Don't
database/sqldirectly outsideinternal/repository/andinternal/database/ - Don't
fmt.Printlnβ use the package logger - Don't return raw errors from handlers β wrap with context and let the middleware translate to HTTP status
Use this decision tree:
- "I'm exposing a new HTTP route" β
internal/api/, plus the repository/domain code it calls - "I'm adding a new Helix feature" β register in
internal/ai/features/registry.go, add a strategy underinternal/ai/strategies/, a handler underinternal/ai/handlers/, wrap the route ininternal/api/ai_routes.go, regenerateweb/src/ai/features.tswithgo run ./tools/aigen - "I'm adding a new Tesla command" β
internal/tesla/client_commands.go - "I'm adding a new alert rule family" β
internal/alerts/rules/ - "I'm adding a new notification channel" β
internal/notifications/channels/ - "I'm adding a new background job" β either a new method on an existing worker or a new
cmd/<worker-name>/binary if the responsibility is large
web/
βββ src/
β βββ api/
β β βββ hooks/ # TanStack Query hooks (the only data-loading layer)
β β βββ client.ts # request() β adds /api/v1, headers, auth
β βββ ai/
β β βββ features.ts # AUTO-GENERATED from Go registry (never edit)
β β βββ withAiFeature.ts # HOC that returns null if feature is off
β βββ components/
β β βββ ui/ # Buttons, inputs, selects, modals (shared)
β β βββ charts/ # Chart primitives wrapping Recharts
β β βββ maps/ # Map primitives wrapping Leaflet
β β βββ data-display/ # <Distance>, <Speed>, <Currency>, <DateTime>, etc.
β β βββ branding/ # HelixMark, logos
β β βββ ai/ # AIFeatureCard, AIBadge, AIThinkingDots, etc.
β β βββ layout/ # Layout shell, sidebar
β βββ features/
β β βββ dashboard/
β β βββ vehicles/
β β βββ charging/
β β βββ alerts/
β β βββ automations/
β β βββ analytics/
β β βββ settings/
β β βββ β¦ # 21 feature areas total
β βββ hooks/ # Shared cross-feature hooks (useSettings, useUnits, β¦)
β βββ lib/ # Pure utilities (date, number, currency, unit conversion)
β βββ i18n/ # English + locale JSON
β βββ styles/ # Tailwind base + custom CSS
βββ public/ # Static assets, PWA manifest
βββ vite.config.ts
features/* βββΆ api/hooks βββΆ api/client βββΆ /api/v1/*
β
ββββΆ components/ui|charts|maps|data-display|branding
ββββΆ hooks/
ββββΆ lib/
- Pages (
features/*/pages/) compose components and call hooks. Nofetch(). No raw SQL strings (obviously). No direct Recharts/Leaflet imports. - Hooks (
api/hooks/) are the only place that callsrequest(). URLs omit/api/v1. Query params are snake_case. - Components (
components/*) are presentation. They take data via props and emit events. - Lib (
lib/) is pure β no React, no hooks. Date, number, currency, unit-conversion utilities.
What NOT to do:
- Don't import Recharts or Leaflet outside
components/charts/orcomponents/maps/ - Don't import directly from
api/clientβ always go through a hook - Don't hardcode units (
km,mph,$,Β°C) β useuseUnits()/useFormatting()/useDateFormat() - Don't render raw HTML form controls (
<button>,<input>,<select>) β use thecomponents/uiequivalents - Don't write
useEffect-driven data fetching β TanStack Query is the canonical pattern - Don't edit
web/src/ai/features.tsby hand β it's generated
- "I'm adding a new page" β
web/src/features/<area>/pages/ - "I'm adding a new shared widget" β
web/src/components/<category>/ - "I'm adding a new data-loading hook" β
web/src/api/hooks/ - "I'm adding a chart" β use existing chart primitives from
web/src/components/charts/; only add a new primitive if no existing one fits - "I'm adding a Helix feature surface" β wrap with
withAiFeature('<feature-id>')and render viaAIFeatureCard
197 numbered SQL files (000001_*.up.sql, 000001_*.down.sql, β¦). Applied in order on API startup. Migrations are append-only β never edit a committed migration; add a new one to evolve.
Code generators and CI vets:
| Tool | Purpose |
|---|---|
aigen |
Generates web/src/ai/features.ts from internal/ai/features/registry.go |
aivet |
Validates the Helix off-by-default + wrapping contract |
migration-lint |
Static checks on new migrations |
The Helm chart. values.yaml is the contract for what you can configure; templates render Kubernetes manifests. Treat values.yaml as a reference document β every option there is something an operator might need to tune.
Provisioned dashboards and scrape config. The Grafana service mounts these at startup so dashboards exist on a fresh stack without manual import.
Workflows, the prompt library used by the team's coding agent, and skills (like audit-violations) that wrap common dev tasks behind a script.
- No monorepo tools (Nx, Turborepo, pnpm workspaces). The project has two language ecosystems (Go and TypeScript) and
go+npmhandle each natively. Adding a monorepo orchestrator on top would be ceremony. - No microservices for microservices' sake. The API and the workers are separate binaries because they have different scaling and restart characteristics, not because they each get their own bounded context. Splitting further would multiply deployment complexity without benefit.
- No client-side state management library (Redux, MobX, Zustand). TanStack Query owns server state, React local state owns ephemeral UI state, the
useSettingscontext owns user preferences. Nothing else has been needed. - No CSS-in-JS runtime. Tailwind for utility, plain CSS for the rest. Predictable, fast, easy to lint.
- Adding Features β the workflow for adding a vertical slice
- API Reference for Contributors β the end-to-end pattern for a new resource
- Architecture β the runtime view, not the source view