This project uses a layered architecture, but the most important rule is not just where files live. The important rule is where business decisions live.
Command handlers in src/commands/ are the Telegram-facing application boundary.
They are expected to:
- receive Telegram input (
message,callback_query,ctx.match,ctx.from) - validate command-specific entry conditions
- perform command-specific permission and ownership checks
- parse and normalize raw user input into DTO-like data for services
- call one or more services
- choose the user-facing response path (
reply,answerCallbackQuery, wizard step, preview update) - trigger presentation updates such as synchronized ride message refreshes
They are not expected to own reusable business rules.
Command handlers should not:
- decide business side effects based on ride state transitions
- encode notification policies
- encode attached-group membership rules
- duplicate participation, ride-state, or sharing rules across handlers
- perform direct storage orchestration when a service can own the use case
Services in src/services/ own business use cases and cross-entity coordination.
They are expected to:
- encapsulate ride and participation business rules
- coordinate storage mutations
- decide what happens when a state transition occurs
- delegate infrastructure work to narrower collaborator services when needed
- return structured outcomes that handlers can map to user-facing messages
Examples of service-owned decisions:
- whether a participation change is allowed
- whether a repeated participation action is a no-op
- whether a participation transition should trigger creator notifications
- whether a participation transition should add or remove a user from an attached group
Service boundaries should use application-level data contracts, not raw Telegram framework objects.
Rules:
- Do not pass
ctxinto services. - Do not pass raw Telegram user objects such as
ctx.frominto service APIs. - If a service only needs to know who performed an action for ownership, filtering, or audit fields, pass
userId. - If a service needs user profile data, pass a normalized
UserProfile.
Use a single application-level user DTO for service methods that need user identity plus display metadata:
{
userId,
username,
firstName,
lastName
}This contract intentionally differs from Telegram's raw shape:
userIdinstead ofidfirstNameinstead offirst_namelastNameinstead oflast_name
Commands and other Telegram-facing entry points are responsible for mapping Telegram user objects into UserProfile before calling services.
Participation should use the same UserProfile contract instead of a separate participant-specific input type when the fields are identical.
If stored participation records later need extra persistence-only fields such as createdAt, those fields should be added by storage or persistence mapping, not required from service callers.
Some project components support a specific layer and should stay focused on that role:
src/telegram/TelegramGateway.jsis the Telegram transport boundarysrc/wizard/owns interactive UI flow state and step navigationsrc/formatters/owns rendering of user-visible ride contentsrc/utils/contains helpers and parsing utilities, not business orchestration
If a piece of logic would need to be reused from another command, callback, wizard path, import flow, or future automation, it probably does not belong in a command handler.
If a handler starts making decisions like "when state changes from X to Y, also do Z", that logic should usually move into a service dedicated to that use case.
This rule is especially relevant for participation flows. The handler should translate Telegram input into a participation request and communicate the result back to the user. The service layer should own participation transition rules such as notifications and attached-group synchronization.