Architecture
group-ride follows a Ports & Adapters (hexagonal) architecture. The domain and business logic live at the centre and depend on nothing external. The outside world (databases, messaging platforms) plugs in through interfaces called ports.
Dependency rule: adapters depend on the domain, never the other way around.
Layers
Section titled “Layers”graph TD subgraph Adapters direction LR DISCORD["Discord adapter\ndiscord.js"] TELEGRAM["Telegram adapter\ngrammY"] SQLITE["SQLite adapter\nbun:sqlite"] PG["PostgreSQL adapter\nBun SQL"] end
subgraph Services RS["RideService"] SS["SchedulerService"] IMP["Importer\nKomoot · Strava · Garmin"] end
subgraph Domain RP["«port»\nRideRepository"] MP["«port»\nMessagingPort"] RIDE["Ride\nCreateRideInput\nRideLevel · RideStatus"] end
DISCORD -->|calls| RS TELEGRAM -->|calls| RS
RS --> RP RS --> MP SS --> RP SS --> MP IMP --> RIDE
SQLITE -.->|implements| RP PG -.->|implements| RP DISCORD -.->|implements| MP TELEGRAM -.->|implements| MP| Layer | Role | May import from |
|---|---|---|
| Domain | Core types and port interfaces | Nothing outside domain/ |
| Services | Business logic | domain/ only |
| Adapters | I/O implementations | domain/, services/, shared adapters |
RideRepository
Section titled “RideRepository”Abstracts persistence. Implemented by SqliteRideRepository and PostgresRideRepository.
classDiagram class RideRepository { <<interface>> +save(ride) +findById(id) +findByThreadId(threadId) +findActive() +findActiveByMember(userId) +update(ride) +addMember(rideId, userId) +hasMember(rideId, userId) +removeMember(rideId, userId) +getMembers(rideId) }
class SqliteRideRepository class PostgresRideRepository { -sql SQL }
SqliteRideRepository ..|> RideRepository : implements PostgresRideRepository ..|> RideRepository : implementsMessagingPort
Section titled “MessagingPort”Abstracts the messaging platform. Implemented by DiscordMessaging and TelegramMessaging.
classDiagram class MessagingPort { <<interface>> +announce(ride) +createThread(ride) +pinSummary(threadId, ride, participants) +updatePinnedSummary(threadId, ride, participants) +closeThread(threadId) +addMemberToThread(threadId, userId, silent?) +removeMemberFromThread(threadId, userId) +notifyThread(threadId, message) +notifyMainChannel(message) }
class DiscordMessaging class TelegramMessaging
DiscordMessaging ..|> MessagingPort : implements TelegramMessaging ..|> MessagingPort : implementsRuntime wiring
Section titled “Runtime wiring”The adapter pair is chosen at startup from environment variables. The domain and services are the same regardless of which adapters are active.
flowchart LR ENV["Environment"]
subgraph Storage SQLITE["SqliteRideRepository\nDATABASE_URL unset"] PG["PostgresRideRepository\nDATABASE_URL set"] end
subgraph Platform DS["startDiscord\nADAPTER=discord (default)"] TG["startTelegram\nADAPTER=telegram"] end
ENV -->|DATABASE_URL| Storage ENV -->|ADAPTER| Platform Storage -->|RideRepository| DS Storage -->|RideRepository| TGFile structure
Section titled “File structure”src/├── domain/│ ├── ride.ts # Ride, CreateRideInput, RideLevel, RideStatus│ └── ports/│ ├── ride.repository.ts # RideRepository interface│ └── messaging.port.ts # MessagingPort interface├── i18n/│ ├── en.ts # English message catalogue│ ├── fr.ts # French message catalogue│ └── index.ts # getMessages() — locale selection via LANG├── services/│ ├── ride.service.ts # RideService — orchestrates ride operations│ ├── scheduler.service.ts # SchedulerService — reminders + auto-close│ └── importer/ # Komoot / Strava / Garmin URL importers└── adapters/ ├── messaging/ │ ├── shared/ │ │ └── parse.ts # Date/stats parsing shared by discord and telegram │ ├── discord/ # discord.js — implements MessagingPort │ │ ├── messaging.ts │ │ ├── commands/ # /newride, /rides, /help │ │ └── handlers/ # join, leave, edit, participants, member events, gpx upload │ └── telegram/ # grammY — implements MessagingPort │ ├── messaging.ts │ ├── conversations/ # multi-step /newride and /edit flows │ └── handlers/ # join, cancel, member events └── database/ ├── sqlite/ # bun:sqlite — implements RideRepository │ ├── db.ts # connection + auto-migration runner │ └── ride.repo.ts └── postgres/ # Bun SQL — implements RideRepository ├── ride.repo.ts └── migrations/ # run manually before first startInternationalisation (i18n)
Section titled “Internationalisation (i18n)”User-facing runtime messages are translated via a lightweight catalogue system in src/i18n/. No external library is used.
src/i18n/├── en.ts # English (default)├── fr.ts # French└── index.ts # getMessages() — reads LANG env var at call timegetMessages() reads process.env.LANG, lowercases it, slices the first two characters (en, fr, …), and returns the matching catalogue, falling back to English if the locale is unknown.
What is translated: all user-facing strings sent through MessagingPort — notifications, reminders, and handler reply messages.
What is not translated: log messages (always English), announcement/summary formats (adapter-specific, in format.ts), help and welcome messages.
Adding a new locale:
- Create
src/i18n/xx.tsexporting amessagesobject with the same shape asen.ts - Register it in
src/i18n/index.ts:import { messages as xx } from "./xx"const LOCALES = { en, fr, xx } - Set
LANG=xxin your environment
Adding a new adapter
Section titled “Adding a new adapter”To add a new messaging platform (e.g. Slack):
- Create
src/adapters/messaging/slack/messaging.tsimplementingMessagingPort - Create
src/adapters/messaging/slack/start.tswiring commands and handlers - Add the
ADAPTER=slackbranch insrc/index.ts - No changes to
domain/orservices/are needed