Architecture Guidelines: Modular Monolith & Domain-Driven Design β
1. Domain & Bounded Contexts β
Our system operates within the Bus Tourism domain (with future expansion planned for broader Mobility Services). The architecture is a Modular Monolith utilizing NestJS, Hasura (GraphQL), and PostgreSQL.
We define four primary Bounded Contexts (Pillars), strictly segregated to prevent tightly coupled code:
- Backoffice Context: Acts as the master record for business configuration, operational staff, abstract product definitions, and financial planning.
- Commerce Context: Handles ticketing, sales, B2C/B2B conversions, capacity holds, and accounting/tax actuals.
- Operations Context: Manages real-world execution, dispatching, fleet telemetry, incident reporting, and driver logistics (formerly "Driver Context").
- Communications Context: A shared core domain providing omnichannel inbox capabilities and automated messaging to all other contexts.
- Customer Intelligence Context:
[future β Phase 3]An event-sourced analytics domain that consumes activity signals from all four operational contexts and produces behavioral aggregates, customer segmentation, and personalized recommendations. Enables the 360Β° Customer Profile vision. See ADR-021.
NOTE
Cross-Context Domain Service β Kalkulations-Engine: The pricing/costing engine (packages/backoffice/pricing) is primarily owned by the Backoffice Context (it produces CostingSheet records from Inventory, Vehicle/Fleet, and PricingRule data). However, it consumes business rules from the Commerce Context (tax strategies such as Β§ 25 UStG Margensteuer, margin targets). Domain events (SeasonPricingFinalized) push the computed gross prices into Commerce caches, maintaining the event-driven decoupling principle. See the Kalkulations-Engine Specification for the full specification.
2. Database Boundary Enforcement β
To maintain strict context boundaries within a shared PostgreSQL database exposed via Hasura:
- PostgreSQL Schemas: Isolate tables into context-specific schemas (e.g.,
commerce.tour_offerings,operations.service_legs,backoffice.operators). Do not use thepublicschema for domain entities. - No Cross-Schema Foreign Keys: Bounded contexts must not enforce foreign keys against each other.
- Reference by ID: Aggregate roots in one context must only store the ID (UUID/String) of entities in another context (e.g.,
commerce.bookingsstorestrip_id, not a relational mapping). - Read-Only SQL Views: When a context requires data owned by another context, utilize schema-bound SQL Views (e.g., Commerce querying a simplified
commerce.trip_catalog_viewderived frombackoffice.trips).
2.1 Multi-Tenant Data Isolation β
Every domain table carries tenant_id UUID NOT NULL referencing backoffice.operators. Two layers enforce isolation. See tenant-isolation-strategy ADR.
Primary: Hasura Permission Rules. Every table's select, insert, update, delete permissions include a filter matching the JWT claim:
# Example: backoffice.tour_templates β role: dispatcher
select_permissions:
filter:
tenant_id: { _eq: "x-hasura-tenant-id" }
columns: [id, tenant_id, title, status, ...]Secondary: Postgres RLS (defense-in-depth). Each tenant-scoped table has a Row Level Security policy as a safeguard against Hasura Action bypasses or direct SQL access:
ALTER TABLE backoffice.tour_templates ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON backoffice.tour_templates
USING (tenant_id = current_setting('app.current_tenant_id')::uuid);NestJS sets app.current_tenant_id via SET LOCAL on each database connection.
Global reference tables (e.g., countries, currencies, vehicle_types) are exempt β they carry no tenant_id and have no RLS policy.
Busflow Staff use the elevated Hasura role busflow_staff with unrestricted select permissions (no tenant_id filter). The system uses this role exclusively for cross-tenant analytics, support, and tenant provisioning.
3. Data Mutation & CQRS Strategy β
To balance the performance of GraphQL with the safety of strict business logic, we adopt a pragmatic Command Query Responsibility Segregation (CQRS) approach:
- Reads (Queries): The system executes these directly via Hasura GraphQL. Frontend applications consume data using Hasura's Role-Based Access Control (RBAC).
- Simple CRUD (Writes): Non-domain state changes (e.g., updating a phone number) bypass NestJS and hit Hasura mutations directly, governed by RBAC.
- Fundamental State Constraints: Absolute domain rules (e.g., seats cannot be negative) are PostgreSQL
CHECKconstraints. - Simple Domain Validations: Hasura Input Validations (Pre-insert Webhooks) handle gateway checks and format validations.
- Complex Domain Logic: Operations requiring calculations, data transformation, or cross-table orchestration MUST route through Hasura Actions to custom NestJS command handlers.
4. Cross-Context Event Communication β
Contexts must communicate asynchronously or via strict event contracts to avoid tight coupling.
- Guaranteed / Database-Backed Events: Use Hasura Event Triggers for durable, retryable events that must occur after the system commits a transaction. We define triggers as TypeScript decorators in NestJS via
@golevelup/nestjs-hasura, making the application code the source of truth (see workflow-orchestration.md). Note: While acting as a fire-after-commit webhook, Hasura Event Triggers differ from a true Transactional Outbox pattern as they lack custom retry queues or strict ordered delivery control. - Internal / Synchronous Routing: Use NestJS
@nestjs/event-emitterfor lightweight, in-memory event routing triggered by webhook handlers.
5. Edge Operations & Asynchronous Processing β
The system requires specific architectural patterns to support field clients, data streaming, and resource-heavy tasks:
- Offline-First & Eventual Consistency: Mobile/field clients operating in low-connectivity environments must employ local-first storage. State changes and operational logs sync to the backend when the system restores connectivity. (A future
[future]offline-sync-strategy.mdwill document a detailed technical specification for this sync protocol). - High-Frequency Ingestion: High-volume data streams (e.g., vehicle telemetry) must bypass standard NestJS CQRS routing where possible, utilizing direct Hasura mutations or a dedicated fast-ingest path to prevent database locking.
- Heavy Asynchronous Processing: Handle resource-intensive tasks (e.g., AI parsing, media processing) asynchronously. Initial requests create a
PENDINGrecord, while background NestJS workers process the payload and update the database state upon completion.
6. Internationalization & Business Rules β
Do not hardcode regional compliance (e.g., taxes, driving regulations) into core entities or frontends.
- Market Context: "Market" or "Jurisdiction" is an explicit domain concept. Associate every relevant transaction or operation with a tenant/market.
- Strategy Pattern (Policies): We abstract calculation rules (e.g., tax logic) into Policies (
ITaxCalculationPolicy). The application dynamically injects the appropriate regional implementation. The generatedTaxRulevalue objects embedded within aCostingSheetrepresent the concrete executed outcome of this policy. - Specification Pattern: Standalone Specification objects encapsulate legal validations (e.g., driving hours compliance), evaluating payloads and returning pass/fail compliance states.
7. Cross-Context Interaction Patterns β
When the same real-world concept spans multiple bounded contexts, three distinct patterns govern how contexts collaborate without violating their boundaries.
7.1 Context Mapping: One Concept, Separate Entities β
A single real-world thing (e.g., a pickup location) must exist as a separate model in each context that uses it. We shape each model by that context's ubiquitous language.
CAUTION
Anti-Pattern β Shared Entity: Adding fields from one context onto another context's entity to "avoid duplication" (e.g., adding is_bookable or display_name to an Operations entity so Commerce can use it). This creates conceptual coupling disguised as pragmatism.
- The authoritative context owns the entity and emits domain events when it changes.
- Consuming contexts maintain local projections (read models or value objects) synced via those events.
- Each projection carries only the fields that context needs β nothing more.
TIP
A useful litmus test: if two contexts would extend the same entity in conflicting directions (Commerce wants a display_name, Operations wants gps_waypoints), they need separate models.
7.2 Cross-Context Reads: CQRS Read Models β
When a UI needs to display data owned by multiple contexts (e.g., a dispatcher dashboard showing boarding points and passenger counts), use a dedicated read model outside both contexts.
- Mechanism: Read-only SQL views or Hasura-computed fields joining across schemas.
- Ownership: The read model belongs to the application/UI layer, not to any bounded context.
- Consistency: Acceptable to be eventually consistent for display; never used for write-side decisions.
IMPORTANT
Write-side coupling (context A mutates context B's data) is always prohibited. Read-side coupling (a view joins A and B for display) is explicitly allowed β this is a fundamental asymmetry of CQRS.
Do not sync volatile, high-frequency data (e.g., booking counts) into the consuming context via events. This duplicates Commerce's state inside Backoffice for no domain reason. Reserve event-driven projections for stable master data needed at transaction time (e.g., syncing a product catalog into Commerce for checkout).
The dispatch board requires a compound availability check. It combines data from Backoffice and Operations. We model this as an application-layer SQL view joining across schemas.
backoffice.crew_members.status = 'ACTIVE'- No
APPROVEDentry inbackoffice.crew_absencesoverlapping the target date - All required entries in
backoffice.crew_qualificationsareVALID backoffice.vehicles.status = 'ACTIVE'- No
vehicle_inspectionswithblocks_dispatch = true - No conflicting
operations.leg_assignmentsfor the target time window - Sufficient rest time per
operations.crew_duty_logs(EU-561/2006 evaluation)
See dispatch-availability-engine.md for SQL view definitions, GraphQL contracts, conflict detection rules, and edge states.
7.3 Cross-Context Writes: Saga Coordination β
When a write operation in one context has consequences in another (e.g., deleting a record that another context references), use a request β assess β confirm β execute choreography:
- Initiating context marks the record as
PENDING_REMOVAL(or equivalent) and emits a request event. - Affected context assesses the impact against its own data and responds with approval or rejection (including actionable data like affected counts and suggested alternatives).
- Human confirmation if the operation is destructive β the UI presents the impact and asks the dispatcher to decide.
- Affected context executes the migration (e.g., reassigning references) and confirms completion.
- Initiating context completes the operation (e.g., hard delete) and emits a final cleanup event.
NOTE
In our modular monolith, we can implement sagas as synchronous domain services in the application layer β calling each module's public API in sequence β instead of requiring async message queues. Both approaches are semantically equivalent; choose based on latency requirements.
Key rule: Each context only mutates its own data. The saga coordinator orchestrates the sequence but never reaches into a context's internals.
8. Context Map β
The following map applies the patterns defined in Β§7 to the four concrete bounded contexts. Each row specifies the upstream (data owner) and downstream (data consumer) context, the relationship type, and the primary synchronization mechanism.
| Upstream | Downstream | Relationship | Sync Mechanism | Notes |
|---|---|---|---|---|
| Backoffice | Commerce | CustomerβSupplier | Domain events (PriceMatrixPublished, SeasonPricingFinalized, TripPublished) β Commerce projections | Backoffice owns master data (Trip, PricingRule). Commerce holds local read models shaped for checkout. |
| Backoffice | Operations | CustomerβSupplier | Domain events β Operations projections | Operations derives ServiceLeg from Backoffice trip data. Crew/fleet status changes trigger dispatch recalculation. |
| Commerce | Operations | Conformist | Read-only SQL views | Operations consumes passenger/booking data as-is for boarding. |
| Commerce | Backoffice | Event Notification | Domain events β Backoffice dashboards | Backoffice uses Commerce actuals for Soll/Ist reconciliation. |
| Operations | Backoffice | Event Notification | Domain events β Backoffice inspection scheduling | Field-reported maintenance escalation. |
| Communications | (all contexts) | Shared Kernel | Generic SendMessageCommand API | Provider-agnostic. Domain contexts fire triggers; Communications owns delivery. |
| Backoffice | Communications | CustomerβSupplier | Templates + Scheduled triggers | Communications resolves and dispatches notification templates. |
| (all contexts) | Customer Intelligence | Event Notification | [future β Phase 3] Domain events β CI activity log | CI consumes all customer-relevant events for behavioral aggregation and recommendations. See ADR-021. |
| Customer Intelligence | Commerce, Communications, Backoffice | Event Notification | [future β Phase 3] Enrichment events β context projections | CI produces personalized offers, channel preferences, and profile enrichment signals. |
See event-catalog.md for the canonical registry of all cross-context domain events with emitter, consumer, and trigger details.
NOTE
No Anti-Corruption Layer (ACL) is currently required. All contexts share a single TypeScript codebase (modular monolith) and a unified ubiquitous language. An ACL becomes necessary only if a context integrates with an external legacy system whose model conflicts with the internal domain (e.g., importing Kuschick ERP data).
See adr-001-boarding-point-strategy.md for a concrete application of all three patterns.