Back to ProjectsTeacupBoutique preview

TeacupBoutique

Overview

A full-stack boutique high tea rental platform built on a microservices architecture. Customers can browse rental products, check availability by date, place orders, and pay online. An admin side handles physical inventory, bookings, and returns. Built in C# (.NET 10) and React (TypeScript).

Showcase

See the Live Appplease allow up to 30 seconds for a cold start as services scale to zero when idle (see Scaling & Cost Considerations below)

Admin Login (To view the product / booking management side): admin@teacupboutique.com.au / Admin@Password1 Test Payment (Stripe test card): 4242 4242 4242 4242 , [any valid date] , [any 3 digits ]

Whilst the project is in a good enough state to showcase, the end goal would be to use this for a business. There is definitely work to be done before getting to that stage. Namely, adding actual products and linking stripe payments to real domain/business plus some more iterations on the business logic.

Tech Stack

Architecture

Five microservices (Auth, Inventory, Orders, Payments, and Notifications) each owning its own PostgreSQL database. A YARP-based API Gateway acts as the single entry point for all client traffic, handling JWT validation, CORS, rate limiting, and injecting user context headers into downstream requests.

V1 - Single VM Architecture

TeacupBoutique v1 system architecture

V2 - Azure Managed Services Architecture

TeacupBoutique v2 system architecture

Services never call each other directly. Instead they communicate via an async message exchange. A service publishes an event describing what happened and has no knowledge of what consumes it, keeping services fully decoupled and making the system resilient to individual service restarts. In v2 this is backed by Azure Service Bus queues (Basic tier) rather than RabbitMQ. The full event contract is documented in the Event Reference section below.

Deployment

V1 - Single VM

The current deployment runs on a single Azure VM (B2als v2). All services are containerised with Docker and orchestrated via Docker Compose. Nginx sits at the edge handling SSL termination via a Cloudflare Origin Certificate and routing traffic to the appropriate container. A GitHub Actions pipeline builds and pushes all images to GHCR on every push to main, then SSHes into the VM to pull the latest images, run any pending EF Core migrations, and restart the stack. A self-hosted message broker (RabbitMQ) handles inter-service events, running as a container alongside the services.

V2 - Azure Managed Services (Live)

V2 replaces the single-VM Docker Compose stack with a fully managed Azure infrastructure, provisioned entirely with Terraform.

The six services (gateway, auth, inventory, orders, payments, notifications) each run as an Azure Container App. The frontend is hosted on Azure Static Web App. All databases migrate to Azure Database for PostgreSQL Flexible Server, with each service retaining its own isolated database. Azure Service Bus replaces RabbitMQ for async messaging, and Azure Key Vault holds all secrets. Auth service Data Protection keys are persisted to Azure Blob Storage so they survive redeployments and container restarts without invalidating existing sessions.

The GitHub Actions CI/CD pipeline uses OIDC federated identity — no stored credentials. On push to main it builds and pushes images to Azure Container Registry, optionally runs EF Core migration jobs via Container App Jobs, then updates each Container App to the new image tag. Infrastructure changes are applied separately via Terraform.

KEDA scale rules are configured on the Service Bus queues so services wake in response to queue depth in addition to HTTP traffic.

Scaling & Cost Considerations

This is a showcase and learning project, not a production system with paying customers. That context justifies some deliberate tradeoffs in favour of cost over responsiveness.

Scale-to-zero: Every Container App except the gateway scales down to zero replicas after 5 minutes of inactivity. The gateway stays warm to handle the initial HTTP request and fan out wake pings to all downstream services. On first visit the frontend calls a dedicated wake endpoint on the gateway, which fires parallel health checks to each service, prompting their cold starts before any real user requests arrive. Expect up to ~30 seconds on a cold start; acceptable for a demo, not for a real product.

Service Bus - queues over topics: Azure Service Bus Basic tier only supports queues; topics and subscriptions require Standard tier, which carries a per-namespace per-day base cost regardless of usage. Each queue in this system has exactly one consumer - but for events with multiple consumers (order cancelled, payment succeeded), the publisher writes directly to multiple queues rather than relying on topic subscriptions. This publisher-side fan-out achieves the same result without the standing charge. Basic tier is billed purely on message operations, so a quiet showcase site costs close to nothing.

Cost comparison: The v1 B2als v2 VM ran ~$30-35 AUD/month regardless of traffic. With v2, the primary standing cost is the PostgreSQL Flexible Server (Burstable B1ms, always-on). The Container Apps environment, Service Bus Basic, Static Web App free tier, and supporting resources add relatively little on top, landing at roughly $10-15 AUD/month under normal showcase traffic - and closer to $5 during idle stretches where the container apps scale to zero. The tradeoff is cold-start latency; the saving is real money on a project generating zero revenue.

Notifications cold start: The notifications service scales to zero along with everything else, woken by a KEDA rule that watches its Service Bus queues. When a message arrives (email verification, order confirmation, etc.) the container cold-starts before processing it. For a showcase this delay is acceptable; for real transactional email it would not be.

For a production deployment the right answer would be minimum replica counts of 1 on critical services, Standard tier Service Bus with topic/subscription fan-out if multiple consumers ever appear, and a larger PostgreSQL SKU with compute always on. The current setup is intentionally optimised for cost on a project where the goal was learning the Azure services, not operating them at scale.

Event Reference

Auth Service

DirectionEventTrigger
Publishesauth.EmailVerificationRequestedUser registers
Publishesauth.PasswordResetRequestedUser requests password reset
Publishesauth.EmailChangedUser changes email
Publishesauth.EmailChangeVerificationRequestedEmail change requires verification
Publishesauth.PasswordChangedUser changes password

Inventory Service

DirectionEventTrigger
Publishesinventory.StockReservedStock successfully held for order
Publishesinventory.StockUnavailableItems not available for requested dates
Publishesinventory.BookingCancelledAdmin cancels booking
Publishesinventory.BookingCompletedAdmin marks booking as returned
Publishesinventory.ReturnAssessedWithMissingItemsReturn assessment flags missing items
Subscribesorders.OrderPlacedCheck and reserve stock
Subscribesorders.OrderCancelledRelease reserved stock
Subscribespayments.PaymentSucceededConfirm booking is active

Orders Service

DirectionEventTrigger
Publishesorders.OrderPlacedCustomer submits order
Publishesorders.ReadyForPaymentStock confirmed reserved
Publishesorders.OrderConfirmedPayment succeeded
Publishesorders.OrderCancelledOrder cancelled
Publishesorders.OrderCompletedBooking marked as completed
Publishesorders.PaymentFailedPayment failed, notify customer
Publishesorders.RefundRequestedCancellation/return triggers refund
Subscribesinventory.StockReservedLock price, move to AwaitingPayment
Subscribesinventory.StockUnavailableMark order as OutOfStock
Subscribesinventory.BookingCancelledUpdate order on booking cancellation
Subscribesinventory.BookingCompletedTrigger deposit refund after return
Subscribesinventory.ReturnAssessedWithMissingItemsUpdate order for missing items
Subscribespayments.PaymentSucceededConfirm order
Subscribespayments.PaymentFailedMark payment failed
Subscribespayments.RefundSucceededUpdate order refund status
Subscribespayments.RefundFailedFlag refund failure

Payments Service

DirectionEventTrigger
Publishespayments.PaymentSucceededStripe webhook: payment_intent.succeeded
Publishespayments.PaymentFailedStripe webhook: payment_intent.payment_failed
Publishespayments.RefundSucceededRefund processed via Stripe
Publishespayments.RefundFailedStripe refund failed
Subscribesorders.ReadyForPaymentCreate Stripe payment intent
Subscribesorders.RefundRequestedProcess refund via Stripe API

Notifications Service

DirectionEventTrigger
Subscribesorders.OrderConfirmedSend order confirmation email
Subscribesorders.OrderCompletedSend order completed email
Subscribesorders.OrderCancelledSend cancellation email
Subscribesorders.PaymentFailedSend payment failure email
Subscribesauth.EmailVerificationRequestedSend email verification link
Subscribesauth.PasswordResetRequestedSend password reset link
Subscribesauth.EmailChangedSend email changed notification
Subscribesauth.EmailChangeVerificationRequestedSend email change verification link
Subscribesauth.PasswordChangedSend password changed notification

Features

Challenges & Learnings

Getting the event choreography right across five services was the most demanding part. Ensuring messages are idempotent, handling service startup ordering with retry loops, and keeping order status consistent when events are replayed or arrive late.

On the infrastructure side, configuring Nginx to handle SSL termination via Cloudflare Origin Certificates and ensuring it restarted after the upstream containers (to avoid stale IP resolution) were small but fiddly details. The GitHub Actions CI/CD pipeline builds and pushes all service images in parallel before SSHing into the VM to run EF Core migrations and restart the stack.

The v2 migration introduced its own set of challenges. Replacing RabbitMQ with Azure Service Bus required abstracting the messaging layer cleanly enough that the service code stayed unchanged — only the infrastructure wiring differed. Persisting ASP.NET Data Protection keys to Blob Storage was a small but critical fix; without it, every Container App revision invalidated all existing auth cookies. Switching from topic/subscription to plain queues meant rethinking event routing slightly, but since every event in this system has exactly one consumer the semantics are identical and the cost savings justified the change.

Separating Orders and Inventory into distinct services means each owns its data with no shared database and no referential integrity between them. To handle this, the Orders service snapshots pricing and customer details at the time of purchase so that updating a product contents or pricing later never corrupts a historical order. The Orders schema also went through some rethinking along the way. Fields that seemed useful upfront, like event type, guest count, and special requests, ended up unused once the actual flow took shape.

Links