Skip to content

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.

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

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 : implements

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 : implements

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| TG
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 start

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 time

getMessages() 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:

  1. Create src/i18n/xx.ts exporting a messages object with the same shape as en.ts
  2. Register it in src/i18n/index.ts:
    import { messages as xx } from "./xx"
    const LOCALES = { en, fr, xx }
  3. Set LANG=xx in your environment

To add a new messaging platform (e.g. Slack):

  1. Create src/adapters/messaging/slack/messaging.ts implementing MessagingPort
  2. Create src/adapters/messaging/slack/start.ts wiring commands and handlers
  3. Add the ADAPTER=slack branch in src/index.ts
  4. No changes to domain/ or services/ are needed