Describes how the system stores, manages, and flows information. Covers data structure, ownership, lifecycle, and the key transformations data undergoes.
Aggregate Ownership & Tenancy
Every aggregate in the system belongs to exactly one ownership scope, which determines how it is isolated and queried. Platform-scoped aggregates (User, Customer) carry no vendor affiliation. Vendor-scoped aggregates carry both VendorId and VendorType — this pair is the multi-tenancy boundary that prevents one vendor from accessing another’s data. Customer-scoped aggregates belong to a specific customer and encompass all purchasing, preference, and notification data.
flowchart TD
subgraph PlatformScope["Platform-scoped (no VendorId)"]
User["User\n(UserId, Role, CustomerId?, VendorId?)"]
Customer["Customer\n(CustomerId, Name, ContactInfo, Address)"]
Admin["Platform Admin context"]
end
subgraph VendorScope["Vendor-scoped (VendorId + VendorType)"]
Vendor["Vendor\n(FlowerShop | FreelanceFlorist)"]
Employee["Employee"]
Bouquet["Bouquet\n(BouquetId, VaseId, Status, FreshnessScore)"]
SmartVase["SmartVase\n(VaseId, Location, ConnectionStatus)"]
end
subgraph CustomerScope["Customer-scoped (CustomerId)"]
Cart["Cart + CartItems\n(vendor-scoped constraint)"]
Order["Order + OrderLines\n(multi-line, VendorId at header)"]
Favourite["Favourite"]
Subscription["Subscription"]
Notification["Notification"]
DeviceReg["DeviceRegistration"]
SavedAddr["SavedAddress"]
Feedback["BouquetFeedback"]
end
User --> Customer
User --> Vendor
Vendor --> Employee
Vendor --> SmartVase
Vendor --> Bouquet
SmartVase --> Bouquet
Customer --> Cart
Customer --> Order
Customer --> Favourite
Customer --> Subscription
Customer --> Notification
Customer --> DeviceReg
Customer --> SavedAddr
Order --> Feedback
Sub-domain Mapping
| Aggregate / Scope |
Business Sub-domain |
Notes |
User (platform-scoped) |
Identity & Tenancy |
Polymorphic root linking to either a Customer or a Vendor; carries Role, VendorId?, CustomerId? |
Vendor (FlowerShop / FreelanceFlorist) |
Vendor Management |
The multi-tenancy anchor; all vendor-scoped data is isolated by VendorId + VendorType |
Employee |
Vendor Management |
Belongs to a vendor; used for role-based access within the vendor portal |
SmartVase |
Vase & Bouquet Lifecycle / IoT |
Vendor-owned IoT device; tracks location, connection status, and the currently assigned bouquet |
Bouquet |
Vase & Bouquet Lifecycle |
Vendor-scoped product; transitions through Available → Reserved → Sold / Expired |
Customer (platform-scoped) |
Customer Purchasing |
Holds contact and address data; created when a User with Role = User is onboarded |
Cart + CartItem |
Customer Purchasing |
Vendor-scoped (single-vendor constraint); DB-backed and device-persistent |
Order + OrderLine |
Customer Purchasing / Payments |
Multi-line order with VendorId at the header; drives the payment and fulfilment state machine |
Favourite / Subscription |
Customer Purchasing |
Preference data enabling notifications and personalised discovery |
Notification / DeviceRegistration |
Customer Purchasing / IoT |
Push delivery infrastructure linking a customer’s device token to in-app and FCM notifications |
SavedAddress |
Customer Purchasing |
Convenience data for repeat checkout flows |
BouquetFeedback |
Customer Purchasing |
Post-purchase rating attached to an Order, scoped to a Bouquet |
Data Lifecycle — Bouquet
stateDiagram-v2
[*] --> Available : Vendor creates bouquet\n& assigns to vase
Available --> Reserved : Customer adds to cart\n+ order created (15 min lock)
Reserved --> Available : Reservation expires\nor order cancelled
Reserved --> Sold : Payment confirmed
Available --> Expired : Admin / vendor marks expired\nor TTL exceeded
Sold --> [*]
Expired --> [*]
Sub-domain Mapping — Bouquet Lifecycle
| State |
Business Sub-domain |
Trigger |
Available |
Vase & Bouquet Lifecycle |
Vendor creates a bouquet and assigns it to a vase |
Reserved |
Customer Purchasing |
Customer adds bouquet to cart and order is created (15-minute lock) |
Available (from Reserved) |
Customer Purchasing |
Reservation expires or order is cancelled — bouquet returns to market |
Sold |
Payments |
Stripe webhook confirms payment; ownership transferred |
Expired |
Vase & Bouquet Lifecycle |
Admin/vendor marks bouquet expired, or TTL exceeded |
Data Lifecycle — Order
stateDiagram-v2
[*] --> PendingPayment : POST /orders\nBouquets reserved
PendingPayment --> PaymentProcessing : POST /orders/{id}/confirm-payment
PaymentProcessing --> Confirmed : Stripe webhook success
PaymentProcessing --> PaymentFailed : Stripe webhook failure
PaymentFailed --> PendingPayment : Customer retries
Confirmed --> ReadyForPickup : Vendor marks ready
Confirmed --> OutForDelivery : Vendor dispatches
ReadyForPickup --> Completed : Customer collects
OutForDelivery --> Completed : Delivery confirmed
Confirmed --> Cancelled : Admin / vendor cancels
PendingPayment --> Cancelled : Reservation expires
Completed --> [*]
Cancelled --> [*]
Sub-domain Mapping — Order Lifecycle
| State |
Business Sub-domain |
Trigger |
PendingPayment |
Customer Purchasing |
POST /orders — bouquets reserved, awaiting payment |
PaymentProcessing |
Payments |
Customer initiates Stripe checkout |
Confirmed |
Payments |
Stripe webhook success — payment settled |
PaymentFailed |
Payments |
Stripe webhook failure — customer can retry |
ReadyForPickup / OutForDelivery |
Vendor Order Fulfilment |
Vendor updates order status via Vendor Portal |
Completed |
Vendor Order Fulfilment |
Customer collects or delivery confirmed |
Cancelled |
Customer Purchasing / Vendor Order Fulfilment |
Admin, vendor, or reservation timeout |
Core Data Flows
flowchart LR
subgraph Write["Write Path (Command)"]
CH["Command Handler\n(MediatR)"]
AG["Aggregate\n(domain logic)"]
OB["outbox_messages\n(PostgreSQL)"]
EV["Domain Event"]
CH --> AG --> EV
AG --> OB
end
subgraph Read["Read Path (Query)"]
QH["Query Handler\n(MediatR)"]
RC["Redis Cache\n(CachingBehaviour)"]
PG["PostgreSQL\n(read replica)"]
QH -->|check| RC
RC -->|miss| PG
PG --> RC
end
subgraph Dispatch["Async Dispatch"]
OBD["OutboxDispatcherService\n(every 5s)"]
BUS["InMemoryEventBus"]
SIG["BouquetHub\n(SignalR)"]
OBD -->|poll outbox| BUS --> SIG
end
Write --> Dispatch
Sub-domain Mapping — Data Flows
| Path |
Business Sub-domain |
What moves |
| Write path (Command → Aggregate → Outbox) |
All sub-domains |
Business state mutations — bouquet creation, order placement, vase assignment |
| Read path (Query → Redis → PostgreSQL) |
Customer Purchasing / Vendor Management |
Bouquet search results, order history, vendor dashboards — served from cache on hot paths |
| Async dispatch (Outbox → EventBus → SignalR) |
IoT / Real-time + Customer Purchasing |
Freshness updates and order status events broadcast to connected clients |
| Store |
Technology |
What it holds |
Retention / TTL |
| Primary DB |
PostgreSQL 15 |
All aggregates: Vendor, Bouquet, SmartVase, Order, Cart, Customer, etc. |
Permanent (soft deletes where applicable) |
| Query Cache |
Redis 7 |
Serialised query results keyed by {type}:{version}:{params} |
Per-query TTL; invalidated by version-key increment on writes |
| Token Blacklist |
Redis 7 |
Revoked JWT jti values |
Until JWT expiry |
| Idempotency Keys |
Redis 7 |
idempotency:{key} → {status, body} |
24 hours |
| Outbox |
PostgreSQL (table outbox_messages) |
Serialised domain events awaiting dispatch |
Until processed_at set; GC after 7 days |
| Geocoding Cache |
IMemoryCache (in-process) |
Nominatim OSM reverse-geocode results |
Short TTL, process-local only |
Strongly-Typed ID Cross-Reference
flowchart LR
VendorId["VendorId (Guid)"] -->|scopes| SmartVase2["SmartVase"]
VendorId -->|scopes| Bouquet2["Bouquet"]
VendorId -->|scopes| Employee2["Employee"]
VendorId -->|header of| Order2["Order"]
VendorId -->|header of| Cart2["Cart"]
BouquetId["BouquetId (Guid)"] --> OrderLine2["OrderLine"]
BouquetId --> Favourite2["Favourite"]
BouquetId --> CartItem2["CartItem"]
BouquetId --> Feedback2["BouquetFeedback"]
CustomerId["CustomerId (Guid)"] --> Order3["Order"]
CustomerId --> Cart3["Cart"]
CustomerId --> Favourite3["Favourite"]
CustomerId --> Subscription2["Subscription"]
CustomerId --> Notification2["Notification"]
VaseId["VaseId (Guid)"] --> Bouquet3["Bouquet (AssignedVaseId)"]
VaseId --> CartItem3["CartItem (VaseId)"]