• English
  • Architecture

    High-level topology

    Store App (Flutter POS)          Consumer Browser          Operator Browser
           │                              │                        │
           ▼                              ▼                        ▼
      ┌─────────┐                   ┌──────────┐            ┌──────────┐
      │ ALB     │◄──────────────────│ nginx     │───────────►│  Route   │
      │ (cloud) │                   │ (local)  │            │  Rules   │
      └────┬────┘                   └────┬─────┘            └────┬─────┘
           │                             │                       │
      ┌────┴─────────────────────────────┴───────────────────────┴────┐
      │                        Service Mesh                          │
      │  (AWS Service Connect / Docker Compose DNS)                  │
      └────┬────────────────────────────────────────────────────┬────┘
           │                                                    │
      ┌────┴─────┐  ┌──────────┐  ┌────────┐  ┌───────────┐    │
      │Ingestion │  │ Accounts │  │Booking │  │  Admin     │   ...
      └────┬─────┘  └────┬─────┘  └───┬────┘  └─────┬──────┘
           │              │            │             │
           └──────────────┴────────────┴─────────────┘
    
                  ┌──────┴──────┐
                  │  PostgreSQL │  (shared RDS, schema-separated)
                  └─────────────┘

    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

    ServiceSchemaSource
    Ingestioningestionsrc/SalonCloud.Ingestion/, src/SalonCloud.Infrastructure/
    DataHubdata_hubsrc/SalonCloud.DataHub.Infrastructure/ (migration-only, no runtime service)

    Platform services

    ServiceSchemaSource
    Accountsaccountssrc/SalonCloud.Accounts/, src/SalonCloud.Accounts.Infrastructure/
    Bookingbookingsrc/SalonCloud.Booking/, src/SalonCloud.Booking.Infrastructure/
    Adminadminsrc/SalonCloud.Admin/, src/SalonCloud.Admin.Infrastructure/
    Engineenginesrc/SalonCloud.Engine/, src/SalonCloud.Engine.Infrastructure/
    Pushpushsrc/SalonCloud.Push/, src/SalonCloud.Push.Infrastructure/
    Messagingmessagingsrc/SalonCloud.Messaging/, src/SalonCloud.Messaging.Infrastructure/
    Observabilityobservabilitysrc/SalonCloud.Observability/, src/SalonCloud.Observability.Infrastructure/

    Cross-cutting libraries

    LibrarySourcePurpose
    Mediasrc/SalonCloud.Media/S3/local media storage abstraction, image validation
    Sharedsrc/SalonCloud.Shared/CapabilityPoller, telemetry enrichers

    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.Infra under infra/

    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.

    Technology stack

    LayerTechnology
    Runtime.NET 10 (LTS)
    Web frameworkASP.NET Core Minimal API
    ORMEF Core (migrations + control-plane)
    Raw DB accessNpgsql (hot ingestion path)
    DatabasePostgreSQL 16 (AWS RDS)
    ComputeECS Fargate (PROD) / EC2 t3.micro (UAT)
    Load balancerAWS ALB
    Service meshAWS Service Connect / Docker Compose DNS
    IaCAWS CDK (C#)
    CI/CDGitHub Actions
    Container runtime.NET chiseled (minimal, non-root)
    Test frameworkxUnit + Testcontainers + WebApplicationFactory