Architecture
High-level topology
Bounded contexts
Every microservice is an independent deployable: its own container, its own CDK Fargate service (or EC2 task in UAT), its own bounded context, and its own PostgreSQL schema on a shared RDS instance.
Invariant: one service = one schema. A service never reads or writes another service's schema directly. The sole documented exception: data_hub is the blessed shared read substrate — services read its security_invoker views via a granted role.
Service catalog
Core data pipeline
Platform services
Cross-cutting libraries
Naming conventions
- API services:
SalonCloud.<Domain>(e.g.,SalonCloud.Ingestion) - Data layers:
SalonCloud.<Domain>.Infrastructure(e.g.,SalonCloud.Ingestion.Infrastructure) - Cross-cutting primitives:
SalonCloud.Shared - Infrastructure-as-code:
SalonCloud.Infraunderinfra/
Cross-service communication patterns
Internal bridges (HTTP)
Services communicate over HTTP with an X-Internal-Secret header validated by a shared secret. Each consumer has a typed bridge class for each dependency:
- Admin → Accounts:
AccountsActivationBridge,AccountsDeviceBridge,AccountsStoreQueryBridge,AccountsStoreStateBridge,AccountsEntitlementBridge,AccountsOfflineDeviceBridge - Admin → Engine:
EngineHealthBridge,EngineTaskBridge - Admin → Observability:
ObservabilityBridge - Admin → Booking:
BookingStoreQueryBridge - Booking → Push:
PushBridgeNudge - Booking → Messaging:
MessagingOtpSender,MessagingDeliveryBridge,MessagingPreferenceBridge - Engine → Accounts:
AccountsStoreQueryBridge
Capability poller (shared pull pattern)
Instead of push-based event propagation, services pull store state changes from Accounts via CapabilityPoller (in SalonCloud.Shared). Each consumer implements ICapabilityCursorStore (cursor persistence) and ICapabilitySink (idempotent state application). Polling runs every 15 seconds via CapabilityPollerHostedService.
Consumers: Ingestion, Booking.
Media storage
Store logos and cover images are stored in S3 (production) or local filesystem (development) via the IMediaStorage interface. Ingestion handles uploads; the CDK MediaStack provisions the S3 bucket + CloudFront.
Design decisions
Raw Npgsql for the hot path
Ingestion's LandingRepository uses raw NpgsqlCommand with unnest() arrays for the ON CONFLICT ... DO UPDATE upsert — not EF Core. EF Core is used only for migrations and control-plane queries. This was chosen because bulk upsert performance matters for the sync pipeline, and the LWW guard condition requires precise SQL control.
Pull-based capability sync
Pull, not push, is the contract for state distribution. A consumer that was down simply reads from its cursor when it comes back — there's no lost-delivery problem, no outbox to back up. The cursor is a keyset over a globally monotonic sequence, so a store whose state changes mid-scan still appears (its row only moves forward).
DataHub views instead of materialized tables
DataHub projects ingestion.ingest_rows through live PostgreSQL views with security_invoker = true. This avoids replication lag and materialization complexity. The views are always current because they query the landing table directly. The tradeoff is that a slow query affects all consumers — mitigated by the try_* fail-safe cast functions that prevent any one store's bad data from taking down cross-tenant queries.
PII-tiered access
data_hub.customers (non-PII: tags, spend, credit, visits, points) is readable by both datahub_app (per-store RLS) and datahub_maint (cross-tenant). data_hub.customers_pii (name, phone, email, notes, birthday) is restricted to datahub_app only. datahub_maint can also never read the raw data_hub.rows view.
More details on the data pipeline in Data Flow.