Case Study

Architecture Overview

How a monolith, microservices, and a strangler-fig migration run side-by-side in one codebase — toggled per session, routed by a single gateway.

The Problem

Most portfolio projects show one architecture style. But real systems evolve. Monoliths get decomposed. Services get extracted. Data has to stay in sync during the transition.

The question I wanted to answer: how do you demonstrate a monolith-to-microservices migration without a big-bang rewrite, and let someone switch between both paths to see the difference?

The Solution

Three architectural paths running simultaneously in the same codebase:

A clean architecture monolith with DDD, CQRS, and a custom decorator pipeline. A set of decomposed microservices built with FastEndpoints across four bounded contexts. And a connector API implementing the strangler-fig pattern with bidirectional sync between both sides.

A YARP reverse proxy sits in front of everything. It reads an x-mode header on every request and routes to the monolith or microservices accordingly. Each user session picks its own path — no backend state change, no restart.

Admin toolbar with monolith/micro toggle button
The admin toolbar lets visitors toggle between monolith and microservices mode per session

Architecture

The gateway is the single entry point. It inspects the x-mode header — set by the frontend based on a localStorage toggle — and proxies to either the monolith API or the microservices cluster.

Both paths share SQL Server as their primary database and the same messaging infrastructure (RabbitMQ via Dapr pub/sub). PostgreSQL is used exclusively by the AI service for pgvector embeddings. A connector API and reverse connector API handle bidirectional sync: when a company is created in the monolith, the connector saga fans out to provision it across all four microservices. When created in microservices, the reverse connector syncs it back to the monolith.

Jaeger traces comparing gateway routing to microservices (top) vs monolith (bottom)
Jaeger traces side-by-side: microservices path fans out to admin-api and company-api (top), monolith path routes directly to monolith-api (bottom)

What You See

From the admin app, click the mode toggle in the toolbar. The UI stays the same — same forms, same pages, same data. But behind the scenes, the entire backend path changes.

Create a company in monolith mode, then switch to microservices mode. The company is there — the connector saga synced it. Open Jaeger and compare the two traces: one shows a single monolith span, the other shows the request fan out across four services.

Jaeger trace showing saga connector fanning out across services
Saga orchestration fanning out a company creation across admin, company, job, and user APIs
Dapr dashboard showing sidecar topology and component bindings
Dapr dashboard showing sidecar topology and component bindings across all services

Behind the Scenes

The monolith uses a transactional outbox to publish domain events reliably. Events are written to an outbox table in the same transaction as the domain change, then dispatched to RabbitMQ by a background processor. This guarantees at-least-once delivery without distributed transactions.

The connector API implements an orchestrated saga for cross-service provisioning. When a company is created in the monolith, the saga calls each microservice in sequence, tracks completion state, and handles compensating actions on failure. Redis idempotency keys prevent duplicate processing.

The reverse connector handles the opposite direction: microservice events flow back to the monolith. This bidirectional sync means both paths always have the same data, which is the key requirement for a strangler-fig migration — you can switch traffic gradually without data loss.

Key Decisions

YARP direct proxy over Dapr service invocation

Why: YARP gives full control over routing rules, header manipulation, and load balancing. Dapr invocation adds indirection that complicates debugging.

Alternative: Dapr service invocation with custom middleware. Rejected because it would require reimplementing routing logic that YARP handles out of the box.

Transactional outbox over direct event publishing

Why: Direct publishing to RabbitMQ risks message loss if the process crashes after the database commit but before the publish completes. The outbox guarantees atomicity.

Alternative: Change Data Capture (CDC) from the database transaction log. More complex to operate.

Orchestrated saga over choreography

Why: With four microservices involved in provisioning, choreography creates implicit coupling and makes failure handling opaque. An orchestrator makes the flow explicit and debuggable in traces.

Alternative: Event choreography. Simpler initially but harder to reason about as the number of services grows.

Docker Compose over Kubernetes

Why: For a portfolio running on a Proxmox homelab, Kubernetes adds operational overhead without proportional benefit. Docker Compose with Dapr sidecars provides service mesh capabilities at a fraction of the complexity.

Alternative: K3s or MicroK8s. Would demonstrate Kubernetes experience but at the cost of maintaining a cluster.

Tradeoffs & Lessons Learned