Case Study
Architecture Decision Records
20 ADRs documenting every significant architectural decision — context, alternatives considered, and rationale for each trade-off.
Why ADRs Matter
Every significant architectural decision in this project is documented as an Architecture Decision Record. ADRs capture why a decision was made, not just what was built. They show how I think about trade-offs: what alternatives were considered, what constraints existed, and what the consequences are.
These aren't retroactive documentation — they were written at the time each decision was made, forming a decision log that any team member could reference.
All Architecture Decision Records
001Architecture Showcase ScopeThree architectural paths side-by-side: monolith, microservices, and strangler-fig migration.
Context
This repository demonstrates real-world engineering and architectural decision-making. A portfolio that only demonstrates one style hides important tradeoffs: operability vs simplicity, consistency vs autonomy, migration strategy vs greenfield purity.
Decision
Intentionally include three architectural approaches within the same domain:
- Clean Architecture Monolith — maintainability, DDD boundaries, CQRS, operational readiness
- Microservices — service decomposition and distributed workflows
- Connector API (Strangler-Fig) — incremental migration and integration patterns
Consequences
- Demonstrates breadth: design, migration, integration, distributed concerns
- Enables "compare and contrast" discussions in interviews
- Increased repo complexity and cognitive load (mitigated by ADRs and clear boundaries)
002Dapr Usage BoundariesDapr for local dev portability, Azure managed services for production.
Context
Dapr provides pub/sub, configuration, and secrets for local development. The target production environment is Azure, where first-party managed services provide stronger guarantees.
Decision
Use Dapr as an integration abstraction for local development, with a clear exit strategy for Azure:
| Capability | Local | Azure Target |
|---|---|---|
| Configuration | Dapr + Redis | Azure App Configuration |
| Secrets | Dapr + Vault | Azure Key Vault |
| Pub/Sub | Dapr + RabbitMQ | Azure Service Bus |
Consequences
- Strong local developer experience without Azure dependency
- Clear migration path to managed services
- Demonstrates real-world cloud transition thinking
003Transactional OutboxAtomic state changes + event publishing with retries and dead-letter handling.
Context
Publishing integration events directly after saving data can lead to inconsistencies: DB commits without event publish, or vice versa.
Decision
Implement a transactional outbox: persist an outbox record in the same transaction as the state change. A background processor publishes undispatched records with retry policies.
Consequences
- Strong reliability guarantees — no "lost events"
- Controlled retries and backoff strategies
- Slight delay between commit and delivery (eventual consistency)
UPDLOCK, READPASTlocking for concurrency safety
004Observability-FirstSystems judged by diagnosis speed, release safety, and operational confidence.
Context
For senior roles, operability is a core competency. Portfolios often omit this.
Decision
Treat observability as a first-class design constraint:
- Distributed tracing across services and key internal operations
- Structured logs with correlation identifiers
- Health checks separated into liveness/readiness and dependency checks
- Trace/span names reflect business operations, not framework internals
Consequences
- Faster debugging and root-cause analysis
- Stronger "production-readiness" signal for reviewers
- Adds up-front work and ongoing discipline
005CQRS + Decorator PipelineCustom decorator chain instead of MediatR with explicit cross-cutting concerns.
Context
Many projects implement CQRS with MediatR. We want to demonstrate CQRS as a pattern, not a library dependency.
Decision
Implement CQRS with explicit Command/Query contracts, handlers, and a decorator pipeline:
- Validation — FluentValidation rules
- Transaction — DB transaction (skip with
INoTransactionmarker) - Observability — logging, metrics, OpenTelemetry spans
- UserContext — auth check + user sync
Consequences
- Reduced framework lock-in; clearer intent
- Cross-cutting concerns composable and testable in isolation
- Slightly more scaffolding than MediatR
006RabbitMQ vs Azure Service BusEnvironment-specific messaging: RabbitMQ for dev, Service Bus for prod.
Context
The portfolio demonstrates event-driven communication. Messaging infrastructure should be intentionally selected based on environment.
Decision
RabbitMQ locally for fast iteration and visibility. Azure Service Bus for production with enterprise-grade durability.
| Capability | RabbitMQ | Azure Service Bus |
|---|---|---|
| Hosting | Self-managed | Fully managed |
| Local Dev | Excellent | Poor (cloud-only) |
| Dead-Lettering | Yes | Yes (first-class) |
| Ops Overhead | Higher | Lower |
Consequences
- Clean separation between domain logic and transport
- Realistic dev-to-prod story
- Requires contract stability and idempotent consumers
007Trace Context PropagationW3C TraceContext across HTTP, messaging, and background processors.
Context
Debugging distributed systems requires correlating frontend requests with backend execution.
Decision
- Propagate W3C Trace Context across all services
- Return TraceId in HTTP response headers
- Correlate logs, traces, and events
Consequences
- Faster debugging with full end-to-end traceability
- Improved operability
- Slight increase in instrumentation complexity
008YARP Gateway Direct ProxyDirect HTTP proxying instead of Dapr invocation to avoid header corruption.
Context
The YARP gateway initially proxied through Dapr service invocation. Dapr's HTTP-to-gRPC conversion corrupted Authorization headers, and the oauth2clientcredentials middleware replaced user JWTs with machine tokens.
Decision
YARP proxies directly to backend services via Docker DNS, bypassing Dapr for the proxy layer. Dapr sidecar retained for configuration and secrets only.
Lessons Learned
- Dapr service invocation is not a transparent proxy
oauth2clientcredentialsmiddleware overwrites the Authorization header- Test auth flows end-to-end in the deployment environment, not just locally
009AI Multi-Provider Function CallingOpenAI, Azure OpenAI, Gemini, Claude via Microsoft.Extensions.AI.
Context
The AI service needs multiple LLM providers to avoid vendor lock-in, enable cost comparison, and demonstrate provider-agnostic design.
Decision
Adopt Microsoft.Extensions.AI as the provider abstraction. Each provider registered as a keyed singleton IChatClient. Provider selection is runtime-configurable via Redis.
FunctionInvokingChatClienthandles automatic tool-call loops- Topology-aware tool registries (monolith vs microservices) controlled by feature flag
- Conversations stored in Redis with sliding 40-message window
Consequences
- Switching providers requires only a config change, not code
- Unified tool definitions work across all providers
- Same binary supports both deployment topologies
010Real-Time AI Notifications (SignalR)Server-pushed notifications for immediate UI updates during AI tool execution.
Context
When AI tools create or modify entities, the user needs immediate feedback beyond the chat response — navigation, data refresh, and visual confirmation.
Decision
SignalR hub at /hubs/notifications with user-scoped groups. Notifications carry W3C Trace Context for end-to-end trace continuity.
Consequences
- Immediate UI feedback the moment a tool completes
- End-to-end trace continuity visible in Jaeger
- Targeted delivery via user-scoped groups
- Extensible — new notification types require only a new type value
011Strangler-Fig Connector API & SagasFan-out provisioning across microservices with orchestrated coordination.
Context
When a company is created in the monolith, data must be provisioned across multiple microservices with atomicity, idempotency, and observability.
Decision
A dedicated Connector API subscribes to outbox events and orchestrates provisioning via sagas:
- Fetch data — parallel OData queries to monolith
- Fan-out —
Task.WhenAlldispatches to 3 services in parallel - Activate — posts results back to monolith
- Idempotency — Redis state keys with TTLs (120s processing, 7-day completion)
Why Orchestration
- Activation step depends on results from user-api
- Error attribution is straightforward
- Trace spans map naturally to saga phases
012Reverse Connector (Bidirectional Sync)Microservice writes synced back to monolith to close the data loop.
Context
The forward connector handled monolith-to-microservices flow. But AI chat writes directly to microservices, creating data divergence.
Decision
A reverse-connector-api subscribes to microservice events (micro.* namespace) and replays them to monolith sync endpoints. Uses the same patterns: Dapr pub/sub, Redis idempotency, OpenTelemetry tracing.
Consequences
- Closed bidirectional data loop — all writes reflected everywhere
- Original userId forwarded for audit trail
- Eventual consistency window (typically sub-second)
013Keycloak MigrationAuth0 to self-hosted Keycloak for cost, control, and operational ownership.
Context
Auth0's free tier limits, custom claim complexity, and flat organization model created friction. Self-hosting Keycloak demonstrates infrastructure ownership.
Decision
Hierarchical group-based RBAC replacing Auth0's flat organizations:
/Admins— platform administrators/Companies/{uid}/CompanyAdmins— company admin users/Companies/{uid}/Recruiters— recruiter users/Applicants— job seekers
Consequences
- Hierarchical multi-tenancy without workarounds
- Full ownership of user management and email templates
- Group paths are the single source of authorization truth
- Migration touched ~20 files across 8 services
014Resume Embedding Pipeline (RAG)Multi-phase async parsing, pgvector embeddings, and retrieval-augmented generation.
Context
Resume parsing involves LLM calls (10-30s). Must not block uploads.
Decision
Three-stage event-driven pipeline via transactional outbox:
- Stage 1 (Parse): Download blob, extract text, LLM parses in two phases
- Stage 2 (Embed): Generate batch embeddings for full/skills/experience text (1536-dim)
- Stage 3 (Delete): Remove embedding when resume deleted
Consequences
- Non-blocking uploads with streaming progress via SignalR
- Multi-representation embeddings for targeted retrieval
- End-to-end traceable from upload through embedding
015MCP Server IntegrationAI tools exposed via Model Context Protocol for dynamic discovery and invocation.
Context
In-process AI tools with typed HTTP clients duplicated API knowledge. As the tool surface grew, maintaining duplicate client code became expensive.
Decision
Two MCP servers expose domain tools from the backend:
monolith-mcp(port 3333) — backed by monolith databaseadmin-api-mcp(port 3334) — backed by microservices
Both expose identical tool interfaces. The AI service discovers tools at runtime via McpToolProvider.
Consequences
- Tools live where the logic lives
- Transparent topology migration via feature flag
- Full CQRS pipeline enforcement on every tool call
016Multi-Scope AI ChatThree chat audiences with separate auth, tools, and system prompts.
Context
Three user audiences have different authorization levels, tool access, and conversational goals.
Decision
Three dedicated endpoints with scope-driven tool resolution:
| Endpoint | Policy | Groups |
|---|---|---|
POST /chat | AdminChat | Admins |
POST /chat/company | CompanyAdminChat | Admins, CompanyAdmins |
POST /chat/public | PublicChat | All authenticated |
Consequences
- Least-privilege by design
- Independent evolution of each scope's tool set
- Declarative authorization via framework policies
017IntegrationEvents Shared NuGetEvent schemas packaged as NuGet for consistency across producers and consumers.
Context
Multiple services publish and consume integration events. Without a shared source of truth, event schemas drift.
Decision
All event contracts defined in JobBoard.IntegrationEvents and published as a versioned NuGet package.
Consequences
- Compile-time contract enforcement
- Single source of truth for event types
V1suffix allows introducing V2 events alongside V1
018Docker Deployment TopologySingle Linux host with Docker Compose and Dapr sidecars.
Context
10+ services with Dapr sidecars on a single Linux host. Kubernetes is not used.
Decision
Docker Compose with service + sidecar pattern. Sidecars use network_mode: "service:{service}" to share the network namespace.
Consequences
- Single
docker compose up -dstarts the entire platform - Dapr-native sidecar pattern mirrors Kubernetes
- Dev and prod compose files are structurally identical
019EF Core Dual-ID PatternInteger PK for DB performance + GUID for API exposure.
Context
Every entity needs an integer PK for database performance and a GUID for API responses.
Decision
Dual-ID base entity: int InternalId (DB primary key) + Guid Id (public API identifier via Guid.CreateVersion7()).
Consequences
- Performant clustered indexes with compact integer keys
- Secure API surface — only GUIDs exposed externally
- Pre-insert ID knowledge enables complex aggregate creation
020Aspire Local Orchestration.NET Aspire launches 36 resources with dependency ordering and health checks.
Context
The local environment previously required manually launching 10+ services, 8 Dapr sidecars, Docker Compose for infrastructure, and Angular dev servers.
Decision
A single Aspire AppHost orchestrates ~36 resources with one command. Includes a Seed Runner container for Redis, SQL Server, and PostgreSQL seeding.
Consequences
- Single-command startup
- Dependency ordering with deterministic seeding
- Unified dashboard with logs, traces, and health
- .NET projects run as native processes (debugger-friendly)