Skip to the content.

PoC .NET - CQRS

wakatime Maintainability Test Coverage CodeFactor GitHub license GitHub last commit

πŸ”¬ Proof of Concept of CQRS pattern in .NET using RabbitMQ, ReBus, State Machine, MediatR and Docker

Proof of Concept demonstrating the CQRS pattern in .NET 10 using MediatR, Rebus, RabbitMQ, Stateless State Machine, EF Core, and Docker.


Table of Contents


Architecture Overview

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                        HTTP Client                             β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                            β”‚  REST
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                     CqrsPoC.API                                β”‚
β”‚  OrdersController  β†’  IMediator                                β”‚
└────────┬──────────────────┬───────────────────────────────────-β”˜
         β”‚ Commands         β”‚ Queries
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                  CqrsPoC.Application                           β”‚
β”‚                                                                β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”‚
β”‚  β”‚   Command Handlers  β”‚   β”‚      Query Handlers          β”‚    β”‚
β”‚  β”‚  CreateOrder        β”‚   β”‚  GetOrderQueryHandler        β”‚    β”‚
β”‚  β”‚  ConfirmOrder       β”‚   β”‚  GetAllOrdersQueryHandler    β”‚    β”‚
β”‚  β”‚  ShipOrder          β”‚   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β”‚
β”‚  β”‚  CompleteOrder      β”‚                  β”‚                    β”‚
β”‚  β”‚  CancelOrder        β”‚   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β”‚   IOrderRepository (read)   β”‚    β”‚
β”‚           β”‚                β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β”‚
β”‚           β”‚ IEventPublisher                                    β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚
β”‚  β”‚              LoggingBehavior (pipeline)                  β”‚  β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
         β”‚
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                  CqrsPoC.Infrastructure                        β”‚
β”‚                                                                β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
β”‚  β”‚   AppDbContext       β”‚   β”‚     RebusEventPublisher       β”‚ β”‚
β”‚  β”‚   (EF Core InMemory) β”‚   β”‚     β†’ IBus (Rebus)            β”‚ β”‚
β”‚  β”‚   OrderRepository    β”‚   β”‚     β†’ RabbitMQ exchange       β”‚ β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
β”‚                                            β”‚ publishes        β”‚
β”‚                             β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
β”‚                             β”‚  Event Handlers (Rebus subs)  β”‚ β”‚
β”‚                             β”‚  OrderCreatedEventHandler     β”‚ β”‚
β”‚                             β”‚  OrderConfirmedEventHandler   β”‚ β”‚
β”‚                             β”‚  OrderShippedEventHandler     β”‚ β”‚
β”‚                             β”‚  OrderCompletedEventHandler   β”‚ β”‚
β”‚                             β”‚  OrderCancelledEventHandler   β”‚ β”‚
β”‚                             β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
         β”‚
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                      CqrsPoC.Domain                           β”‚
β”‚                                                                β”‚
β”‚  Order (Aggregate Root)                                        β”‚
β”‚    └─ Stateless State Machine                                  β”‚
β”‚         Pending β†’ Confirmed β†’ Shipped β†’ Completed              β”‚
β”‚         Pending/Confirmed β†’ Cancelled                          β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Tech Stack

Concern Library / Tool Version
Framework .NET / ASP.NET Core 10.0
CQRS Mediator MediatR 12.x
Message Bus Rebus 8.x
Message Transport Rebus.RabbitMq (RabbitMQ) 10.x
State Machine Stateless 5.x
ORM / Persistence EF Core (InMemory for PoC) 10.0
API Docs Swashbuckle / Swagger 7.x
Containerisation Docker + Docker Compose β€”

Project Structure

CqrsPoC/
β”œβ”€β”€ CqrsPoC.sln
β”œβ”€β”€ Dockerfile
β”œβ”€β”€ docker-compose.yml
└── Src/
    β”œβ”€β”€ CqrsPoC.Contracts/          # Shared integration event records
    β”‚   └── Events/
    β”‚       β”œβ”€β”€ OrderCreatedEvent.cs
    β”‚       β”œβ”€β”€ OrderConfirmedEvent.cs
    β”‚       β”œβ”€β”€ OrderShippedEvent.cs
    β”‚       β”œβ”€β”€ OrderCompletedEvent.cs
    β”‚       └── OrderCancelledEvent.cs
    β”‚
    β”œβ”€β”€ CqrsPoC.Domain/             # Pure domain β€” no framework deps
    β”‚   β”œβ”€β”€ Entities/
    β”‚   β”‚   └── Order.cs            ← Aggregate root + state machine
    β”‚   β”œβ”€β”€ Enums/
    β”‚   β”‚   β”œβ”€β”€ OrderState.cs
    β”‚   β”‚   └── OrderTrigger.cs
    β”‚   └── Exceptions/
    β”‚       β”œβ”€β”€ DomainException.cs
    β”‚       └── OrderNotFoundException.cs
    β”‚
    β”œβ”€β”€ CqrsPoC.Application/        # Use-cases, CQRS handlers
    β”‚   β”œβ”€β”€ Commands/
    β”‚   β”‚   β”œβ”€β”€ CreateOrder/
    β”‚   β”‚   β”œβ”€β”€ ConfirmOrder/
    β”‚   β”‚   β”œβ”€β”€ ShipOrder/
    β”‚   β”‚   β”œβ”€β”€ CompleteOrder/
    β”‚   β”‚   └── CancelOrder/
    β”‚   β”œβ”€β”€ Queries/
    β”‚   β”‚   β”œβ”€β”€ GetOrder/
    β”‚   β”‚   └── GetAllOrders/
    β”‚   β”œβ”€β”€ Behaviors/
    β”‚   β”‚   └── LoggingBehavior.cs  ← MediatR pipeline (cross-cutting)
    β”‚   β”œβ”€β”€ Interfaces/
    β”‚   β”‚   β”œβ”€β”€ IOrderRepository.cs
    β”‚   β”‚   └── IEventPublisher.cs
    β”‚   └── DependencyInjection.cs
    β”‚
    β”œβ”€β”€ CqrsPoC.Infrastructure/     # EF Core + Rebus implementations
    β”‚   β”œβ”€β”€ Persistence/
    β”‚   β”‚   β”œβ”€β”€ AppDbContext.cs
    β”‚   β”‚   └── Repositories/
    β”‚   β”‚       └── OrderRepository.cs
    β”‚   β”œβ”€β”€ Messaging/
    β”‚   β”‚   β”œβ”€β”€ RebusEventPublisher.cs
    β”‚   β”‚   └── Handlers/
    β”‚   β”‚       β”œβ”€β”€ OrderCreatedEventHandler.cs
    β”‚   β”‚       β”œβ”€β”€ OrderConfirmedEventHandler.cs
    β”‚   β”‚       β”œβ”€β”€ OrderShippedEventHandler.cs
    β”‚   β”‚       β”œβ”€β”€ OrderCompletedEventHandler.cs
    β”‚   β”‚       └── OrderCancelledEventHandler.cs
    β”‚   └── DependencyInjection.cs
    β”‚
    └── CqrsPoC.API/                # HTTP entry point
        β”œβ”€β”€ Controllers/
        β”‚   └── OrdersController.cs
        β”œβ”€β”€ Program.cs
        β”œβ”€β”€ appsettings.json
        └── appsettings.Development.json

Order Lifecycle β€” State Machine

The Order aggregate embeds a Stateless state machine that enforces all valid lifecycle transitions at the domain level. Invalid transitions throw a DomainException, which the API maps to 400 Bad Request.

                    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                    β”‚ Pending β”‚ ◄─── Initial state on creation
                    β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”˜
          [confirm]      β”‚       [cancel]
              β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
              β–Ό                         β–Ό
       β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”             β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
       β”‚ Confirmed β”‚             β”‚ Cancelled β”‚ (terminal)
       β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜             β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
    [ship]   β”‚       [cancel]         β–²
             β”‚β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
             β–Ό
         β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”
         β”‚Shipped β”‚
         β””β”€β”€β”€β”€β”¬β”€β”€β”€β”˜
   [complete] β”‚
              β–Ό
        β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
        β”‚ Completed β”‚ (terminal)
        β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Each OrderDto response includes a permittedTriggers array so clients always know which transitions are available in the current state.


CQRS Flow

Command flow (write side)

HTTP PUT /api/orders/{id}/confirm
  └─► OrdersController.Confirm()
        └─► IMediator.Send(ConfirmOrderCommand)
              └─► LoggingBehavior (pipeline)
                    └─► ConfirmOrderCommandHandler
                          β”œβ”€β–Ί IOrderRepository.GetByIdAsync()
                          β”œβ”€β–Ί order.Confirm()          ← state machine fires
                          β”œβ”€β–Ί IOrderRepository.SaveChangesAsync()
                          └─► IEventPublisher.PublishAsync(OrderConfirmedEvent)
                                └─► Rebus IBus.Publish()
                                      └─► RabbitMQ exchange
                                            └─► OrderConfirmedEventHandler (subscriber)

Query flow (read side)

HTTP GET /api/orders/{id}
  └─► OrdersController.GetById()
        └─► IMediator.Send(GetOrderQuery)
              └─► LoggingBehavior (pipeline)
                    └─► GetOrderQueryHandler
                          └─► IOrderRepository.GetByIdAsync()
                                └─► OrderDto (projection)

Getting Started

Prerequisites


Run with Docker Compose

# Clone / navigate to the repo root
git clone <repo-url>
cd CqrsPoC

# Build and start both services (RabbitMQ + API)
docker compose up --build

# API Swagger UI  β†’  http://localhost:8080
# RabbitMQ UI     β†’  http://localhost:15672  (guest / guest)

To stop and remove containers:

docker compose down -v

Run Locally (without Docker)

  1. Start RabbitMQ via Docker:
docker run -d \
  --name rabbitmq \
  -p 5672:5672 \
  -p 15672:15672 \
  rabbitmq:3.13-management-alpine
  1. Run the API:
cd Src/CqrsPoC.API
dotnet run
# Swagger UI β†’ https://localhost:5001  (or check the console output)

API Reference

Method Endpoint Description Transition
GET /api/orders List all orders β€”
GET /api/orders/{id} Get a single order β€”
POST /api/orders Create a new order β†’ Pending
PUT /api/orders/{id}/confirm Confirm a pending order Pending β†’ Confirmed
PUT /api/orders/{id}/ship Ship a confirmed order Confirmed β†’ Shipped
PUT /api/orders/{id}/complete Complete a shipped order Shipped β†’ Completed
PUT /api/orders/{id}/cancel Cancel a pending/confirmed order β†’ Cancelled

Example: Create Order

curl -X POST http://localhost:8080/api/orders \
  -H "Content-Type: application/json" \
  -d '{
    "customerName": "Guilherme",
    "productName": "Mechanical Keyboard",
    "amount": 149.99
  }'
# β†’ 201 Created  { "id": "3fa85f64-..." }

Example: Full lifecycle

ID="<paste-id-from-create>"

curl -X PUT http://localhost:8080/api/orders/$ID/confirm
curl -X PUT http://localhost:8080/api/orders/$ID/ship
curl -X PUT http://localhost:8080/api/orders/$ID/complete

# Check final state
curl http://localhost:8080/api/orders/$ID

Error responses

Domain and transition errors return RFC 7807 Problem Details:

{
  "status": 400,
  "title": "DomainException",
  "detail": "Cannot apply trigger 'Ship' when order is in state 'Pending'."
}

Design Decisions

Clean Architecture layers

Dependencies flow inward: API β†’ Application β†’ Domain.
Infrastructure implements interfaces defined in Application β€” so the domain and use-cases have zero framework dependencies.

MediatR pipeline behaviours

LoggingBehavior<TRequest,TResponse> is registered as an open generic pipeline behaviour, giving structured logging + timing for every Command and Query without touching individual handlers.

Rebus + RabbitMQ

Rebus acts as the integration event bus. After a command mutates state, the handler publishes a typed event record (IEventPublisher), which Rebus routes to RabbitMQ. Subscribers (also in Infrastructure) react asynchronously β€” decoupling side-effects from the command path.

The IEventPublisher abstraction in the Application layer means handlers never reference Rebus directly, keeping the transport swappable.

Stateless state machine inside the aggregate

The state machine lives inside the Order aggregate as a private field. It is rebuilt on every instantiation (including EF Core hydration), reads the persisted State column, and mutates it only through domain methods (Confirm(), Ship(), etc.).
This keeps the machine as an enforcement mechanism β€” not just documentation.

EF Core InMemory

Used for simplicity in this PoC. Swap UseInMemoryDatabase for UseSqlServer / UseNpgsql / etc. in Infrastructure/DependencyInjection.cs and add a migration to go production-ready.


Testing

The solution contains three test projects under the /Tests folder, covering the full testing pyramid.

Tests/
β”œβ”€β”€ CqrsPoC.Tests.Unit/            # Fast, isolated β€” no I/O, pure logic
β”‚   β”œβ”€β”€ Domain/
β”‚   β”‚   └── OrderStateMachineTests.cs    (26 tests)
β”‚   └── Application/
β”‚       β”œβ”€β”€ Commands/CommandHandlerTests.cs  (17 tests)
β”‚       β”œβ”€β”€ Queries/QueryHandlerTests.cs     (8 tests)
β”‚       └── Behaviors/LoggingBehaviorTests.cs (4 tests)
β”‚
β”œβ”€β”€ CqrsPoC.Tests.Integration/    # Real EF Core + real Rebus (in-memory transport)
β”‚   β”œβ”€β”€ Persistence/
β”‚   β”‚   └── OrderRepositoryTests.cs      (8 tests)
β”‚   β”œβ”€β”€ Messaging/
β”‚   β”‚   └── RebusEventHandlerTests.cs    (5 tests)
β”‚   └── Infrastructure/
β”‚       └── ApplicationPipelineTests.cs  (10 tests)
β”‚
└── CqrsPoC.Tests.E2E/            # Full HTTP stack via WebApplicationFactory
    └── Endpoints/
        └── OrdersApiTests.cs            (20 tests)

Run all tests

dotnet test

Run by project

# Unit only
dotnet test Tests/CqrsPoC.Tests.Unit

# Integration only
dotnet test Tests/CqrsPoC.Tests.Integration

# E2E only
dotnet test Tests/CqrsPoC.Tests.E2E

Test strategy

Layer What’s tested Mocked
Unit Domain state machine transitions, command/query handler orchestration, MediatR pipeline behaviour IOrderRepository, IEventPublisher
Integration EF Core repository CRUD, Rebus in-memory event delivery, full MediatR+handler pipeline IEventPublisher only (no RabbitMQ)
E2E All HTTP endpoints, status codes, response bodies, Problem Details errors, full lifecycle IEventPublisher (Moq), RabbitMQ (InMemory Rebus transport), EF Core (InMemory DB)