DEV Community

Naimul Karim
Naimul Karim

Posted on

Building a Cloud-Native Event Ticketing System with DDD, Event-Driven Architecture & CQRS in .NET 8

Building a Cloud-Native Event Ticketing System with DDD, Event-Driven Architecture & CQRS in .NET 8

Full source code: github.com/naimulkarim/TicketingSystem

Stack: .NET 8 · Angular 17 · SQL Server · RabbitMQ · Redis · MassTransit · MediatR · YARP · Docker · GitHub Actions


When you buy a concert ticket online, a deceptively large amount happens in the few seconds between clicking "Purchase" and receiving your confirmation email. Seats must be reserved atomically before payment is taken. Payment can fail — in which case the seat must be released so another customer can buy it. A confirmation email must fire. An admin dashboard must stay consistent. None of this can be lost, even if a service crashes mid-workflow.

This article walks through how I designed and built a production-grade online ticketing platform for concerts, sports, and live shows — from domain modelling through to Docker Compose and CI. Every code snippet comes from the real repository at github.com/naimulkarim/TicketingSystem with direct file links.

The three patterns at the heart of the design:

  • Domain-Driven Design (DDD) — keeps business logic in the domain, not scattered across controllers or services
  • Event-Driven Architecture (EDA) — decouples services through asynchronous messages and a saga for distributed workflows
  • CQRS — separates the write model (commands) from the read model (queries) for clarity and independent scalability

Table of Contents

  1. The Problem Domain & Why These Patterns
  2. Architecture Overview — The 30,000 ft View
  3. Solution Structure & Full File Map
  4. The Shared Kernel — DDD Building Blocks
  5. Bounded Context 1: Identity Service
  6. Bounded Context 2: Event Catalog Service
  7. Bounded Context 3: Booking Service — DDD + CQRS + Saga
  8. Bounded Context 4: Payment Service
  9. Bounded Context 5: Notification Service
  10. The API Gateway — YARP
  11. Event-Driven Architecture Deep Dive
  12. The Booking Saga — Distributed Workflow
  13. The Outbox Pattern — Guaranteed Delivery
  14. Redis Seat Locking — Preventing Overselling
  15. The Angular Frontend — NgRx Architecture
  16. All Features & Use Cases — End to End
  17. Infrastructure: Docker Compose & SQL Server
  18. CI/CD: GitHub Actions Pipeline
  19. Unit Tests
  20. What to Build Next
  21. Conclusion & GitHub Link

1. The Problem Domain & Why These Patterns

An event ticketing system exposes several genuinely hard problems:

Problem Why it's hard Pattern that solves it
Two users booking the last seat simultaneously Race condition — DB locks don't scale Redis distributed lock (SET NX)
Payment fails after seat is reserved Must release seat as compensation Saga with compensating transactions
Service crashes between saving booking and publishing event Message lost — booking stuck Outbox pattern (atomic write + publish)
High read traffic for event search vs. low-frequency writes Write complexity pollutes read performance CQRS — separate read/write paths
Notification service needs to react to bookings Tight coupling if called directly Integration events on RabbitMQ
Business rules scattered across controllers Impossible to test, easy to break Aggregates with invariant enforcement

These are not hypothetical edge cases. They are the daily reality of any system that handles money and inventory under concurrent load.


2. Architecture Overview

┌────────────────────────────────────────────────────────────────────┐
│                         CLIENT TIER                                │
│              Angular 17 SPA (NgRx · OIDC PKCE · Signals)          │
└─────────────────────────────┬──────────────────────────────────────┘
                              │ HTTPS / JWT
                              ▼
┌────────────────────────────────────────────────────────────────────┐
│                      API GATEWAY (YARP)                            │
│           Auth · Rate Limiting (300 req/min) · Routing             │
│                     localhost:5000                                  │
└──────┬──────────────┬────────────────┬────────────────┬────────────┘
       │              │                │                │
       ▼              ▼                ▼                ▼
┌──────────┐  ┌──────────────┐  ┌──────────┐  ┌──────────────┐
│ Identity │  │ EventCatalog │  │ Booking  │  │   Payment    │
│ :5001    │  │ :5002        │  │ :5003    │  │   :5004      │
│          │  │              │  │ ◄── SAGA │  │              │
│ OAuth2   │  │ Events       │  │ Aggreg.  │  │ Stripe PSP   │
│ OIDC     │  │ Venues       │  │ CQRS     │  │ Refunds      │
│ JWT      │  │ Seat Maps    │  │ Outbox   │  │              │
└──────────┘  └──────────────┘  └────┬─────┘  └──────┬───────┘
                                     │               │
                    ┌────────────────▼───────────────▼──────────┐
                    │         RabbitMQ Message Bus               │
                    │   Domain Events · Integration Events       │
                    │   Dead-Letter Queues · Saga State          │
                    └───────────┬────────────────┬──────────────┘
                                │                │
                    ┌───────────▼──┐  ┌──────────▼──────────┐
                    │ Notification │  │  Read Model          │
                    │ :5005        │  │  Projectors          │
                    │ Email/SMS    │  │  (future)            │
                    └──────────────┘  └─────────────────────┘

DATA TIER
┌──────────────┐  ┌────────────────────────────────────────────┐
│    Redis     │  │  SQL Server (one database per service)      │
│  Seat locks  │  │  Booking_DB · EventCatalog_DB · Payment_DB  │
│  TTL: 10min  │  │  Identity_DB                                │
└──────────────┘  └────────────────────────────────────────────┘

OBSERVABILITY
┌────────────────────────────────────────────────────────────────┐
│  OpenTelemetry → Jaeger (tracing) · Prometheus → Grafana       │
│  Serilog (structured logs) · /health endpoints per service     │
└────────────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Key architectural decisions:

  • One database per service — no shared schema, no cross-service joins. Queries that need data from multiple services use the read model or a BFF.
  • RabbitMQ as the only cross-service channel — services never call each other's HTTP APIs directly. All integration happens through published events and commands on the bus.
  • Saga in Booking — the Booking service owns the checkout workflow and coordinates Payment via commands on RabbitMQ.
  • Outbox in every write service — domain events are atomically persisted to an outbox table in the same DB transaction as aggregate state, guaranteeing no message is lost.

3. Solution Structure & Full File Map

TicketingSystem/                              ← github.com/naimulkarim/TicketingSystem
│
├── TicketingSystem.sln
├── docker-compose.yml
├── .env.example
├── setup-github.sh
├── GITHUB_SETUP.md
│
├── src/
│   ├── SharedKernel/
│   │   ├── SharedKernel.csproj
│   │   ├── Domain/
│   │   │   ├── AggregateRoot.cs
│   │   │   ├── Primitives.cs               ← DomainEvent, ValueObject, Entity<T>
│   │   │   └── Result.cs                   ← Result<T> error pattern
│   │   ├── Application/Behaviours/
│   │   │   └── PipelineBehaviours.cs       ← ValidationBehaviour, LoggingBehaviour
│   │   └── Infrastructure/
│   │       └── OutboxMessage.cs
│   │
│   ├── Services/
│   │   ├── Identity/
│   │   │   ├── Api/Program.cs
│   │   │   ├── Api/IdentityConfig.cs       ← OAuth2 clients, scopes, resources
│   │   │   ├── Identity.csproj
│   │   │   └── Dockerfile
│   │   │
│   │   ├── EventCatalog/
│   │   │   ├── Domain/Aggregates/Event.cs  ← Event + SeatMap aggregates
│   │   │   ├── Application/EventHandlers.cs← Commands, Queries, Validators
│   │   │   ├── Infrastructure/Persistence/EventCatalogDbContext.cs
│   │   │   ├── Api/Controllers/EventsController.cs
│   │   │   ├── Api/Program.cs
│   │   │   ├── EventCatalog.csproj
│   │   │   └── Dockerfile
│   │   │
│   │   ├── Booking/
│   │   │   ├── Domain/
│   │   │   │   ├── Aggregates/Booking.cs   ← Booking aggregate root
│   │   │   │   ├── Events/BookingEvents.cs ← Domain + integration events
│   │   │   │   └── Repositories/IBookingRepository.cs
│   │   │   ├── Application/
│   │   │   │   ├── Commands/CreateBookingCommand.cs
│   │   │   │   ├── Queries/GetBookingQuery.cs
│   │   │   │   └── Sagas/BookingSagaStateMachine.cs
│   │   │   ├── Infrastructure/
│   │   │   │   ├── Persistence/BookingDbContext.cs
│   │   │   │   ├── Persistence/BookingRepository.cs
│   │   │   │   └── RedisSeatLockService.cs
│   │   │   ├── Api/Controllers/BookingsController.cs
│   │   │   ├── Api/Program.cs
│   │   │   ├── Booking.csproj
│   │   │   └── Dockerfile
│   │   │
│   │   ├── Payment/
│   │   │   ├── Domain/Aggregates/Payment.cs
│   │   │   ├── Infrastructure/Messaging/ProcessPaymentConsumer.cs
│   │   │   ├── Infrastructure/Persistence/PaymentDbContext.cs
│   │   │   ├── Api/Program.cs
│   │   │   ├── Payment.csproj
│   │   │   └── Dockerfile
│   │   │
│   │   └── Notification/
│   │       ├── Application/Consumers/BookingConsumers.cs
│   │       ├── Api/Program.cs
│   │       ├── Notification.csproj
│   │       └── Dockerfile
│   │
│   └── ApiGateway/
│       ├── Program.cs
│       ├── appsettings.json                ← YARP routes + clusters
│       ├── ApiGateway.csproj
│       └── Dockerfile
│
├── frontend/
│   ├── src/app/
│   │   ├── app.component.ts               ← root shell + nav
│   │   ├── app.config.ts                  ← NgRx, router, OAuth providers
│   │   ├── app.routes.ts                  ← lazy-loaded feature modules
│   │   ├── core/
│   │   │   ├── guards/auth.guard.ts
│   │   │   ├── interceptors/auth.interceptor.ts
│   │   │   └── services/auth.service.ts
│   │   ├── features/
│   │   │   ├── events/event-list.component.ts
│   │   │   ├── events/event-detail.component.ts
│   │   │   ├── account/my-bookings.component.ts
│   │   │   └── admin/admin-events.component.ts
│   │   └── store/
│   │       ├── auth/auth.reducer.ts
│   │       ├── booking/booking.reducer.ts
│   │       └── events/events.reducer.ts
│   ├── Dockerfile
│   └── nginx.conf
│
├── tests/
│   └── Unit/
│       ├── Booking/BookingAggregateTests.cs
│       ├── Booking/CreateBookingCommandHandlerTests.cs
│       └── SharedKernel/ValueObjectTests.cs
│
└── .github/workflows/ci.yml
Enter fullscreen mode Exit fullscreen mode

4. The Shared Kernel — DDD Building Blocks

📁 src/SharedKernel/

The Shared Kernel has no business logic and no external dependencies beyond FluentValidation and MediatR abstractions. It defines the DDD primitives that every service inherits.

AggregateRoot

// src/SharedKernel/Domain/AggregateRoot.cs

public abstract class AggregateRoot<TId> where TId : notnull
{
    public TId Id { get; protected set; } = default!;

    private readonly List<DomainEvent> _domainEvents = new();
    public IReadOnlyList<DomainEvent> DomainEvents => _domainEvents.AsReadOnly();

    protected void RaiseDomainEvent(DomainEvent domainEvent) =>
        _domainEvents.Add(domainEvent);

    public void ClearDomainEvents() => _domainEvents.Clear();
}
Enter fullscreen mode Exit fullscreen mode

The aggregate collects domain events in memory during a business operation. The infrastructure layer (DbContext's SaveChangesAsync override) reads them, writes them to the outbox table, and clears them — all in one atomic transaction. This is the core of reliable event publishing.

DomainEvent, ValueObject, Entity

// src/SharedKernel/Domain/Primitives.cs

public abstract record DomainEvent
{
    public Guid Id { get; init; } = Guid.NewGuid();
    public DateTime OccurredOnUtc { get; init; } = DateTime.UtcNow;
}

public abstract class ValueObject
{
    protected abstract IEnumerable<object?> GetEqualityComponents();

    public override bool Equals(object? obj)
    {
        if (obj is null || obj.GetType() != GetType()) return false;
        return ((ValueObject)obj).GetEqualityComponents()
            .SequenceEqual(GetEqualityComponents());
    }

    public override int GetHashCode() =>
        GetEqualityComponents()
            .Select(x => x?.GetHashCode() ?? 0)
            .Aggregate((x, y) => x ^ y);

    public static bool operator ==(ValueObject? left, ValueObject? right) =>
        left?.Equals(right) ?? right is null;

    public static bool operator !=(ValueObject? left, ValueObject? right) =>
        !(left == right);
}
Enter fullscreen mode Exit fullscreen mode

Value objects are immutable and identity-free. Two Money instances with the same amount and currency are equal regardless of reference — the equality is structural, not referential. This models real-world concepts like prices, seat numbers, and currency codes correctly.

Result Pattern

// src/SharedKernel/Domain/Result.cs

public class Result<T>
{
    public T? Value { get; }
    public string? Error { get; }
    public bool IsSuccess { get; }
    public bool IsFailure => !IsSuccess;

    private Result(T value)      { Value = value; IsSuccess = true; }
    private Result(string error) { Error = error; IsSuccess = false; }

    public static Result<T> Success(T value) => new(value);
    public static Result<T> Failure(string error) => new(error);
}
Enter fullscreen mode Exit fullscreen mode

Commands return Result<T> rather than throwing exceptions for expected failures. This forces the caller to handle both paths explicitly.

MediatR Pipeline Behaviours

// src/SharedKernel/Application/Behaviours/PipelineBehaviours.cs

// Runs before every Command and Query — validates the request first
public class ValidationBehaviour<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse> where TRequest : notnull
{
    private readonly IEnumerable<IValidator<TRequest>> _validators;
    public ValidationBehaviour(IEnumerable<IValidator<TRequest>> validators) =>
        _validators = validators;

    public async Task<TResponse> Handle(
        TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken ct)
    {
        if (!_validators.Any()) return await next();

        var context = new ValidationContext<TRequest>(request);
        var failures = _validators
            .Select(v => v.Validate(context))
            .SelectMany(r => r.Errors)
            .Where(f => f != null)
            .ToList();

        if (failures.Count != 0)
            throw new ValidationException(failures);

        return await next();
    }
}

// Structured logging for every request — zero boilerplate in handlers
public class LoggingBehaviour<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse> where TRequest : notnull
{
    private readonly ILogger<LoggingBehaviour<TRequest, TResponse>> _logger;
    public LoggingBehaviour(ILogger<LoggingBehaviour<TRequest, TResponse>> logger) =>
        _logger = logger;

    public async Task<TResponse> Handle(
        TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken ct)
    {
        var name = typeof(TRequest).Name;
        _logger.LogInformation("Handling {RequestName}", name);
        var response = await next();
        _logger.LogInformation("Handled {RequestName}", name);
        return response;
    }
}
Enter fullscreen mode Exit fullscreen mode

Both behaviours are registered as open generics — they automatically apply to every Command and Query in every service without any per-handler wiring:

builder.Services.AddMediatR(cfg =>
{
    cfg.RegisterServicesFromAssemblyContaining<CreateBookingCommand>();
    cfg.AddOpenBehavior(typeof(ValidationBehaviour<,>));
    cfg.AddOpenBehavior(typeof(LoggingBehaviour<,>));
});
Enter fullscreen mode Exit fullscreen mode

5. Bounded Context 1: Identity Service

📁 src/Services/Identity/

The Identity service is the single source of truth for authentication. It runs Duende IdentityServer and issues JWTs. All other services validate tokens against its discovery document — they never call it at runtime beyond token validation.

OAuth2 Client Configuration

// src/Services/Identity/Api/IdentityConfig.cs

public static class IdentityConfig
{
    public static IEnumerable<ApiScope> ApiScopes => new[]
    {
        new ApiScope("booking-api",      "Booking Service"),
        new ApiScope("eventcatalog-api", "Event Catalog Service"),
        new ApiScope("payment-api",      "Payment Service"),
    };

    public static IEnumerable<Client> Clients => new[]
    {
        // Angular SPA — Authorization Code + PKCE (no client secret in the browser)
        new Client
        {
            ClientId = "angular-spa",
            ClientName = "TicketingSystem Angular App",
            AllowedGrantTypes = GrantTypes.Code,
            RequirePkce = true,
            RequireClientSecret = false,                   // public client
            RedirectUris           = { "http://localhost:4200/auth/callback" },
            PostLogoutRedirectUris = { "http://localhost:4200" },
            AllowedCorsOrigins     = { "http://localhost:4200" },
            AllowedScopes = {
                "openid", "profile", "email",
                "booking-api", "eventcatalog-api"
            },
            AccessTokenLifetime = 3600,                    // 1 hour
        },

        // Machine-to-machine: services calling each other
        new Client
        {
            ClientId = "m2m-client",
            ClientSecrets = { new Secret("m2m-secret".Sha256()) },
            AllowedGrantTypes = GrantTypes.ClientCredentials,
            AllowedScopes = { "booking-api", "payment-api", "eventcatalog-api" }
        }
    };
}
Enter fullscreen mode Exit fullscreen mode

Program.cs

// src/Services/Identity/Api/Program.cs

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddIdentityServer(opts =>
{
    opts.Events.RaiseErrorEvents   = true;
    opts.Events.RaiseSuccessEvents = true;
})
.AddInMemoryApiScopes(IdentityConfig.ApiScopes)
.AddInMemoryClients(IdentityConfig.Clients)
.AddInMemoryApiResources(IdentityConfig.ApiResources)
.AddDeveloperSigningCredential(); // ← swap for X.509 cert in production

var app = builder.Build();
app.UseIdentityServer();
app.MapHealthChecks("/health");
app.Run();
Enter fullscreen mode Exit fullscreen mode

Feature delivered: UC-00 — all authenticated flows. The Angular frontend connects via PKCE; backend services use client credentials for M2M calls.


6. Bounded Context 2: Event Catalog Service

📁 src/Services/EventCatalog/

The Event Catalog owns everything about live events: their details, venues, seat maps, and availability. It is the source of truth for what can be booked.

The Event Aggregate

// src/Services/EventCatalog/Domain/Aggregates/Event.cs

public class Event : AggregateRoot<Guid>
{
    public string Title { get; private set; } = null!;
    public string Description { get; private set; } = null!;
    public string Category { get; private set; } = null!;  // Concert | Sports | Theater
    public string Venue { get; private set; } = null!;
    public string City { get; private set; } = null!;
    public DateTime StartsAtUtc { get; private set; }
    public decimal BasePrice { get; private set; }
    public string Currency { get; private set; } = null!;
    public int TotalSeats { get; private set; }
    public int AvailableSeats { get; private set; }
    public bool IsPublished { get; private set; }           // draft until published

    private readonly List<SeatMap> _seats = new();
    public IReadOnlyList<SeatMap> Seats => _seats.AsReadOnly();

    private Event() { }  // EF Core

    public static Event Create(string title, string description, string category,
        string venue, string city, DateTime startsAt,
        decimal basePrice, string currency, int totalSeats)
    {
        var ev = new Event
        {
            Id = Guid.NewGuid(),
            Title = title, Description = description,
            Category = category, Venue = venue, City = city,
            StartsAtUtc = startsAt, BasePrice = basePrice,
            Currency = currency, TotalSeats = totalSeats,
            AvailableSeats = totalSeats,  // starts fully available
            IsPublished = false           // always created as draft
        };

        ev.RaiseDomainEvent(new EventCreatedDomainEvent(ev.Id, title, startsAt));
        return ev;
    }

    public void Publish()
    {
        IsPublished = true;
        RaiseDomainEvent(new EventPublishedDomainEvent(Id, Title));
    }

    // Called when the Booking saga confirms a reservation
    public void ReserveSeat(string seatNumber)
    {
        if (AvailableSeats <= 0)
            throw new InvalidOperationException("No seats available");
        AvailableSeats--;  // invariant: cannot go below 0
    }

    // Called when the Booking saga compensates on payment failure
    public void ReleaseSeat(string seatNumber)
    {
        AvailableSeats++;
    }
}
Enter fullscreen mode Exit fullscreen mode

CQRS — Commands and Queries

// src/Services/EventCatalog/Application/EventHandlers.cs

// ── Create Event Command ──────────────────────────────────────────────────────
public record CreateEventCommand(
    string Title, string Description, string Category,
    string Venue, string City, DateTime StartsAtUtc,
    decimal BasePrice, string Currency, int TotalSeats) : IRequest<Guid>;

public class CreateEventValidator : AbstractValidator<CreateEventCommand>
{
    public CreateEventValidator()
    {
        RuleFor(x => x.Title).NotEmpty().MaximumLength(200);
        RuleFor(x => x.Category).NotEmpty();
        RuleFor(x => x.StartsAtUtc).GreaterThan(DateTime.UtcNow);
        RuleFor(x => x.BasePrice).GreaterThanOrEqualTo(0);
        RuleFor(x => x.TotalSeats).GreaterThan(0);
    }
}

public class CreateEventHandler : IRequestHandler<CreateEventCommand, Guid>
{
    private readonly EventCatalogDbContext _ctx;
    public CreateEventHandler(EventCatalogDbContext ctx) => _ctx = ctx;

    public async Task<Guid> Handle(CreateEventCommand cmd, CancellationToken ct)
    {
        var ev = Event.Create(cmd.Title, cmd.Description, cmd.Category,
            cmd.Venue, cmd.City, cmd.StartsAtUtc,
            cmd.BasePrice, cmd.Currency, cmd.TotalSeats);
        _ctx.Events.Add(ev);
        await _ctx.SaveChangesAsync(ct);
        return ev.Id;
    }
}

// ── Search Events Query (read side — AsNoTracking for performance) ───────────
public record SearchEventsQuery(
    string? City, string? Category, DateTime? From,
    int Page = 1, int PageSize = 20) : IRequest<List<EventDto>>;

public class SearchEventsHandler : IRequestHandler<SearchEventsQuery, List<EventDto>>
{
    private readonly EventCatalogDbContext _ctx;
    public SearchEventsHandler(EventCatalogDbContext ctx) => _ctx = ctx;

    public async Task<List<EventDto>> Handle(SearchEventsQuery q, CancellationToken ct)
    {
        var query = _ctx.Events.AsNoTracking()  // read-only: no change tracking overhead
            .Where(e => e.IsPublished);

        if (!string.IsNullOrEmpty(q.City))     query = query.Where(e => e.City == q.City);
        if (!string.IsNullOrEmpty(q.Category)) query = query.Where(e => e.Category == q.Category);
        if (q.From.HasValue)                   query = query.Where(e => e.StartsAtUtc >= q.From);

        return await query
            .OrderBy(e => e.StartsAtUtc)
            .Skip((q.Page - 1) * q.PageSize)
            .Take(q.PageSize)
            .Select(e => new EventDto(e.Id, e.Title, e.Description, e.Category,
                e.Venue, e.City, e.StartsAtUtc, e.BasePrice, e.Currency,
                e.TotalSeats, e.AvailableSeats, e.IsPublished))
            .ToListAsync(ct);
    }
}
Enter fullscreen mode Exit fullscreen mode

REST Controller

// src/Services/EventCatalog/Api/Controllers/EventsController.cs

[ApiController]
[Route("api/[controller]")]
public class EventsController : ControllerBase
{
    private readonly IMediator _mediator;
    public EventsController(IMediator mediator) => _mediator = mediator;

    // Public — no auth required for browsing
    [HttpGet]
    public async Task<IActionResult> Search(
        [FromQuery] string? city, [FromQuery] string? category,
        [FromQuery] DateTime? from, [FromQuery] int page = 1,
        CancellationToken ct = default)
    {
        var results = await _mediator.Send(new SearchEventsQuery(city, category, from, page), ct);
        return Ok(results);
    }

    [HttpGet("{id:guid}")]
    public async Task<IActionResult> GetById(Guid id, CancellationToken ct)
    {
        var ev = await _mediator.Send(new GetEventByIdQuery(id), ct);
        return ev is null ? NotFound() : Ok(ev);
    }

    // Admin only — requires JWT with Admin role
    [HttpPost]
    [Authorize(Roles = "Admin")]
    public async Task<IActionResult> Create([FromBody] CreateEventCommand cmd, CancellationToken ct)
    {
        var id = await _mediator.Send(cmd, ct);
        return CreatedAtAction(nameof(GetById), new { id }, new { id });
    }

    [HttpPost("{id:guid}/publish")]
    [Authorize(Roles = "Admin")]
    public async Task<IActionResult> Publish(Guid id, CancellationToken ct)
    {
        await _mediator.Send(new PublishEventCommand(id), ct);
        return NoContent();
    }
}
Enter fullscreen mode Exit fullscreen mode

Features delivered: UC-01 Browse Events, UC-02 View Event Details, UC-08 Admin Creates Event, UC-09 Admin Publishes Event.


7. Bounded Context 3: Booking Service — DDD + CQRS + Saga

📁 src/Services/Booking/

The Booking service is the most complex. It owns the checkout workflow, acts as the saga orchestrator, enforces seat locking, and publishes all booking-related events.

The Booking Aggregate

// src/Services/Booking/Domain/Aggregates/Booking.cs

public enum BookingStatus { Pending, Confirmed, Cancelled, Refunded }

public class Booking : AggregateRoot<Guid>
{
    public Guid EventId { get; private set; }
    public Guid UserId { get; private set; }
    public string SeatNumber { get; private set; } = null!;
    public decimal TotalAmount { get; private set; }
    public string Currency { get; private set; } = null!;
    public BookingStatus Status { get; private set; }
    public DateTime CreatedAtUtc { get; private set; }
    public DateTime? ConfirmedAtUtc { get; private set; }
    public string? PaymentReference { get; private set; }

    private Booking() { }  // EF Core

    public static Booking Create(Guid eventId, Guid userId, string seatNumber,
        decimal totalAmount, string currency = "USD")
    {
        var booking = new Booking
        {
            Id = Guid.NewGuid(),
            EventId = eventId,
            UserId = userId,
            SeatNumber = seatNumber,
            TotalAmount = totalAmount,
            Currency = currency,
            Status = BookingStatus.Pending,
            CreatedAtUtc = DateTime.UtcNow
        };

        // Raises event — captured by DbContext and written to Outbox
        booking.RaiseDomainEvent(new BookingCreatedEvent(
            booking.Id, eventId, userId, seatNumber, totalAmount, currency));

        return booking;
    }

    public void Confirm(string paymentReference)
    {
        // Guard the invariant — cannot confirm a non-pending booking
        if (Status != BookingStatus.Pending)
            throw new InvalidOperationException(
                $"Cannot confirm booking in status {Status}");

        Status = BookingStatus.Confirmed;
        PaymentReference = paymentReference;
        ConfirmedAtUtc = DateTime.UtcNow;

        RaiseDomainEvent(new BookingConfirmedEvent(Id, EventId, UserId, paymentReference));
    }

    public void Cancel(string reason)
    {
        if (Status == BookingStatus.Cancelled)
            throw new InvalidOperationException("Booking already cancelled");

        Status = BookingStatus.Cancelled;
        RaiseDomainEvent(new BookingCancelledEvent(Id, EventId, UserId, SeatNumber, reason));
    }
}
Enter fullscreen mode Exit fullscreen mode

Domain Events vs Integration Events

// src/Services/Booking/Domain/Events/BookingEvents.cs

// ── Domain Events (in-process, raised by the aggregate) ──────────────────────
public record BookingCreatedEvent(
    Guid BookingId, Guid EventId, Guid UserId,
    string SeatNumber, decimal TotalAmount, string Currency) : DomainEvent;

public record BookingConfirmedEvent(
    Guid BookingId, Guid EventId, Guid UserId,
    string PaymentReference) : DomainEvent;

public record BookingCancelledEvent(
    Guid BookingId, Guid EventId, Guid UserId,
    string SeatNumber, string Reason) : DomainEvent;

// ── Integration Events (cross-service contracts on RabbitMQ) ─────────────────
// These are the PUBLIC API of the Booking bounded context.
// Other services depend on these types — never on domain events directly.

public record BookingCreatedIntegrationEvent(
    Guid BookingId, Guid EventId, Guid UserId,
    string SeatNumber, decimal TotalAmount, string Currency);

public record BookingConfirmedIntegrationEvent(
    Guid BookingId, Guid UserId, string PaymentReference);

public record BookingCancelledIntegrationEvent(
    Guid BookingId, Guid EventId, string SeatNumber, string Reason);

// ── Commands sent to other services ──────────────────────────────────────────
public record ProcessPaymentCommand(
    Guid BookingId, Guid UserId, decimal Amount, string Currency);

public record ReleaseSeatsCommand(Guid EventId, string SeatNumber);
public record IssueTicketCommand(
    Guid BookingId, Guid EventId, Guid UserId, string SeatNumber);

// ── Result event received from Payment service ────────────────────────────────
public record PaymentProcessedEvent(
    Guid BookingId, bool Success,
    string? PaymentReference, string? FailureReason);
Enter fullscreen mode Exit fullscreen mode

CreateBooking Command + Handler

// src/Services/Booking/Application/Commands/CreateBookingCommand.cs

public record CreateBookingCommand(
    Guid EventId, Guid UserId, string SeatNumber,
    decimal TotalAmount, string Currency = "USD") : IRequest<Guid>;

public class CreateBookingCommandValidator : AbstractValidator<CreateBookingCommand>
{
    public CreateBookingCommandValidator()
    {
        RuleFor(x => x.EventId).NotEmpty();
        RuleFor(x => x.UserId).NotEmpty();
        RuleFor(x => x.SeatNumber).NotEmpty().MaximumLength(20);
        RuleFor(x => x.TotalAmount).GreaterThan(0);
        RuleFor(x => x.Currency).NotEmpty().Length(3);
    }
}

public class CreateBookingCommandHandler : IRequestHandler<CreateBookingCommand, Guid>
{
    private readonly IBookingRepository _repo;
    private readonly IUnitOfWork _uow;
    private readonly ISeatLockService _seatLock;

    public CreateBookingCommandHandler(
        IBookingRepository repo, IUnitOfWork uow, ISeatLockService seatLock)
    {
        _repo = repo;
        _uow = uow;
        _seatLock = seatLock;
    }

    public async Task<Guid> Handle(CreateBookingCommand cmd, CancellationToken ct)
    {
        // Step 1 — Acquire Redis distributed lock for this seat
        var locked = await _seatLock.TryLockAsync(
            cmd.EventId, cmd.SeatNumber, TimeSpan.FromMinutes(10), ct);

        if (!locked)
            throw new InvalidOperationException(
                $"Seat {cmd.SeatNumber} is currently held by another user.");

        // Step 2 — Create the aggregate (raises BookingCreatedEvent internally)
        var booking = Booking.Create(
            cmd.EventId, cmd.UserId, cmd.SeatNumber, cmd.TotalAmount, cmd.Currency);

        // Step 3 — Persist aggregate + outbox message in ONE transaction
        _repo.Add(booking);
        await _uow.SaveChangesAsync(ct);
        // ↑ BookingCreatedEvent → OutboxMessage written atomically here

        return booking.Id;
    }
}
Enter fullscreen mode Exit fullscreen mode

Queries — Read Side

// src/Services/Booking/Application/Queries/GetBookingQuery.cs

public record BookingDto(
    Guid Id, Guid EventId, Guid UserId,
    string SeatNumber, decimal TotalAmount, string Currency,
    string Status, DateTime CreatedAtUtc, DateTime? ConfirmedAtUtc);

public record GetBookingByIdQuery(Guid BookingId) : IRequest<BookingDto?>;
public record GetBookingsByUserQuery(Guid UserId) : IRequest<List<BookingDto>>;

public class GetBookingByIdHandler : IRequestHandler<GetBookingByIdQuery, BookingDto?>
{
    private readonly IBookingRepository _repo;
    public GetBookingByIdHandler(IBookingRepository repo) => _repo = repo;

    public async Task<BookingDto?> Handle(GetBookingByIdQuery query, CancellationToken ct)
    {
        var b = await _repo.GetByIdAsync(query.BookingId, ct);
        return b is null ? null : new BookingDto(
            b.Id, b.EventId, b.UserId, b.SeatNumber,
            b.TotalAmount, b.Currency, b.Status.ToString(),
            b.CreatedAtUtc, b.ConfirmedAtUtc);
    }
}
Enter fullscreen mode Exit fullscreen mode

Repository and Unit of Work

// src/Services/Booking/Infrastructure/Persistence/BookingRepository.cs

public class BookingRepository : IBookingRepository
{
    private readonly BookingDbContext _ctx;
    public BookingRepository(BookingDbContext ctx) => _ctx = ctx;

    public async Task<Booking?> GetByIdAsync(Guid id, CancellationToken ct = default) =>
        await _ctx.Bookings.FirstOrDefaultAsync(b => b.Id == id, ct);

    public async Task<List<Booking>> GetByUserIdAsync(Guid userId, CancellationToken ct = default) =>
        await _ctx.Bookings.Where(b => b.UserId == userId).ToListAsync(ct);

    public void Add(Booking booking)    => _ctx.Bookings.Add(booking);
    public void Update(Booking booking) => _ctx.Bookings.Update(booking);
}

public class UnitOfWork : IUnitOfWork
{
    private readonly BookingDbContext _ctx;
    public UnitOfWork(BookingDbContext ctx) => _ctx = ctx;

    public async Task<int> SaveChangesAsync(CancellationToken ct = default) =>
        await _ctx.SaveChangesAsync(ct);
}
Enter fullscreen mode Exit fullscreen mode

DbContext with Outbox Support

// src/Services/Booking/Infrastructure/Persistence/BookingDbContext.cs

public class BookingDbContext : DbContext
{
    public BookingDbContext(DbContextOptions<BookingDbContext> options) : base(options) { }

    public DbSet<Booking> Bookings => Set<Booking>();
    public DbSet<OutboxMessage> OutboxMessages => Set<OutboxMessage>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Booking>(b =>
        {
            b.HasKey(x => x.Id);
            b.Property(x => x.SeatNumber).HasMaxLength(20).IsRequired();
            b.Property(x => x.Currency).HasMaxLength(3).IsRequired();
            b.Property(x => x.TotalAmount).HasColumnType("decimal(18,2)");
            b.Property(x => x.Status).HasConversion<string>();
        });

        modelBuilder.Entity<OutboxMessage>(b =>
        {
            b.HasKey(x => x.Id);
            b.Property(x => x.EventType).HasMaxLength(300);
            b.Property(x => x.RowVersion).IsRowVersion(); // SQL Server ROWVERSION
        });

        // MassTransit saga persistence tables (auto-created by EF migrations)
        modelBuilder.AddInboxStateEntity();
        modelBuilder.AddOutboxMessageEntity();
        modelBuilder.AddOutboxStateEntity();
    }
}
Enter fullscreen mode Exit fullscreen mode

Full DI Wiring in Program.cs

// src/Services/Booking/Api/Program.cs

var builder = WebApplication.CreateBuilder(args);

// ── SQL Server ────────────────────────────────────────────────────────────────
builder.Services.AddDbContext<BookingDbContext>(opts =>
    opts.UseSqlServer(builder.Configuration.GetConnectionString("SqlServer"),
        sql => sql
            .MigrationsAssembly("Booking")
            .EnableRetryOnFailure(5)));          // retries on transient failures

// ── Repositories ──────────────────────────────────────────────────────────────
builder.Services.AddScoped<IBookingRepository, BookingRepository>();
builder.Services.AddScoped<IUnitOfWork, UnitOfWork>();

// ── Redis ─────────────────────────────────────────────────────────────────────
builder.Services.AddSingleton<IConnectionMultiplexer>(
    ConnectionMultiplexer.Connect(builder.Configuration.GetConnectionString("Redis")!));
builder.Services.AddScoped<ISeatLockService, RedisSeatLockService>();

// ── MediatR + pipeline behaviours ─────────────────────────────────────────────
builder.Services.AddMediatR(cfg =>
{
    cfg.RegisterServicesFromAssemblyContaining<CreateBookingCommand>();
    cfg.AddOpenBehavior(typeof(ValidationBehaviour<,>));
    cfg.AddOpenBehavior(typeof(LoggingBehaviour<,>));
});
builder.Services.AddValidatorsFromAssemblyContaining<CreateBookingCommandValidator>();

// ── MassTransit + RabbitMQ + Saga ─────────────────────────────────────────────
builder.Services.AddMassTransit(x =>
{
    x.AddSagaStateMachine<BookingSagaStateMachine, BookingSagaState>()
        .EntityFrameworkRepository(r =>
        {
            r.ExistingDbContext<BookingDbContext>();
            r.UseSqlServer();  // saga state persisted in SQL Server
        });

    x.SetKebabCaseEndpointNameFormatter();

    x.UsingRabbitMq((ctx, cfg) =>
    {
        cfg.Host(builder.Configuration["RabbitMQ:Host"], "/", h =>
        {
            h.Username(builder.Configuration["RabbitMQ:Username"]!);
            h.Password(builder.Configuration["RabbitMQ:Password"]!);
        });
        cfg.ConfigureEndpoints(ctx);
    });
});

// ── JWT Auth ──────────────────────────────────────────────────────────────────
builder.Services.AddAuthentication("Bearer")
    .AddJwtBearer("Bearer", opts =>
    {
        opts.Authority = builder.Configuration["Identity:Authority"];
        opts.Audience  = "booking-api";
    });

builder.Services.AddControllers();
builder.Services.AddHealthChecks()
    .AddSqlServer(builder.Configuration.GetConnectionString("SqlServer")!)
    .AddRedis(builder.Configuration.GetConnectionString("Redis")!);

var app = builder.Build();

// Auto-migrate on startup — idempotent
using (var scope = app.Services.CreateScope())
    await scope.ServiceProvider
        .GetRequiredService<BookingDbContext>().Database.MigrateAsync();

app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.MapHealthChecks("/health");
app.Run();
Enter fullscreen mode Exit fullscreen mode

Features delivered: UC-03 Reserve Seat, UC-05 Confirm Booking, UC-06 Cancel on Failure, UC-07 View My Bookings, UC-10 Session Abandonment.


8. Bounded Context 4: Payment Service

📁 src/Services/Payment/

The Payment service has no HTTP endpoints exposed to users. It is a pure event consumer — it listens for ProcessPaymentCommand from the Booking saga, charges the customer, and publishes PaymentProcessedEvent back.

The Payment Aggregate

// src/Services/Payment/Domain/Aggregates/Payment.cs

public enum PaymentStatus { Pending, Succeeded, Failed, Refunded }

public class Payment : AggregateRoot<Guid>
{
    public Guid BookingId { get; private set; }
    public decimal Amount { get; private set; }
    public string Currency { get; private set; } = null!;
    public PaymentStatus Status { get; private set; }
    public string? PspReference { get; private set; }   // Stripe PaymentIntent ID
    public string? FailureReason { get; private set; }

    private Payment() { }

    public static Payment Create(Guid bookingId, Guid userId, decimal amount, string currency)
    {
        var p = new Payment
        {
            Id = Guid.NewGuid(), BookingId = bookingId,
            Amount = amount, Currency = currency,
            Status = PaymentStatus.Pending, CreatedAtUtc = DateTime.UtcNow
        };
        p.RaiseDomainEvent(new PaymentInitiatedEvent(p.Id, bookingId, amount));
        return p;
    }

    public void Succeed(string pspReference)
    {
        Status = PaymentStatus.Succeeded;
        PspReference = pspReference;
        RaiseDomainEvent(new PaymentSucceededEvent(Id, BookingId, pspReference));
    }

    public void Fail(string reason)
    {
        Status = PaymentStatus.Failed;
        FailureReason = reason;
        RaiseDomainEvent(new PaymentFailedEvent(Id, BookingId, reason));
    }
}
Enter fullscreen mode Exit fullscreen mode

RabbitMQ Consumer

// src/Services/Payment/Infrastructure/Messaging/ProcessPaymentConsumer.cs

public class ProcessPaymentConsumer : IConsumer<ProcessPaymentCommand>
{
    private readonly PaymentDbContext _ctx;
    private readonly IStripeService _stripe;

    public ProcessPaymentConsumer(PaymentDbContext ctx, IStripeService stripe)
    {
        _ctx = ctx;
        _stripe = stripe;
    }

    public async Task Consume(ConsumeContext<ProcessPaymentCommand> context)
    {
        var cmd = context.Message;

        // Create the payment aggregate
        var payment = Payment.Create(cmd.BookingId, cmd.UserId, cmd.Amount, cmd.Currency);
        _ctx.Payments.Add(payment);

        try
        {
            // Call PSP — swap IStripeService for real Stripe SDK in production
            var pspRef = await _stripe.ChargeAsync(cmd.Amount, cmd.Currency);
            payment.Succeed(pspRef);
        }
        catch (Exception ex)
        {
            payment.Fail(ex.Message);
        }

        await _ctx.SaveChangesAsync(context.CancellationToken);

        // Publish result back — the Booking saga is waiting for this
        await context.Publish(new PaymentProcessedEvent(
            cmd.BookingId,
            payment.Status == PaymentStatus.Succeeded,
            payment.PspReference,
            payment.FailureReason));
    }
}

// Stripe adapter — swap for real Stripe.net SDK in production
public class StripeService : IStripeService
{
    public async Task<string> ChargeAsync(decimal amount, string currency)
    {
        await Task.Delay(100); // simulate PSP round-trip
        return $"pi_{Guid.NewGuid():N}";
    }
}
Enter fullscreen mode Exit fullscreen mode

Features delivered: UC-04 Process Payment, UC-06 Payment Failure Compensation.


9. Bounded Context 5: Notification Service

📁 src/Services/Notification/

The Notification service is the simplest — a pure event consumer with no domain logic and no database. It reacts to integration events and dispatches messages to users.

// src/Services/Notification/Application/Consumers/BookingConsumers.cs

public class BookingConfirmedConsumer : IConsumer<BookingConfirmedIntegrationEvent>
{
    private readonly IEmailService _email;
    private readonly ILogger<BookingConfirmedConsumer> _logger;

    public BookingConfirmedConsumer(IEmailService email,
        ILogger<BookingConfirmedConsumer> logger)
    { _email = email; _logger = logger; }

    public async Task Consume(ConsumeContext<BookingConfirmedIntegrationEvent> ctx)
    {
        _logger.LogInformation("Sending confirmation for booking {BookingId}",
            ctx.Message.BookingId);

        await _email.SendAsync(
            to:      $"user-{ctx.Message.UserId}@example.com",
            subject: "Your ticket is confirmed! 🎟️",
            body:    $"Booking {ctx.Message.BookingId} confirmed. " +
                     $"Payment reference: {ctx.Message.PaymentReference}");
    }
}

public class BookingCancelledConsumer : IConsumer<BookingCancelledIntegrationEvent>
{
    private readonly IEmailService _email;
    public BookingCancelledConsumer(IEmailService email) => _email = email;

    public async Task Consume(ConsumeContext<BookingCancelledIntegrationEvent> ctx)
    {
        await _email.SendAsync(
            to:      $"user-{ctx.Message.BookingId}@example.com",
            subject: "Booking cancelled",
            body:    $"Your booking {ctx.Message.BookingId} was cancelled. " +
                     $"Reason: {ctx.Message.Reason}. " +
                     $"Your seat {ctx.Message.SeatNumber} has been released.");
    }
}

// Swap for SendGrid / SMTP / AWS SES in production
public class SmtpEmailService : IEmailService
{
    private readonly ILogger<SmtpEmailService> _logger;
    public SmtpEmailService(ILogger<SmtpEmailService> logger) => _logger = logger;

    public Task SendAsync(string to, string subject, string body)
    {
        _logger.LogInformation("EMAIL → {To} | {Subject}", to, subject);
        return Task.CompletedTask;
    }
}
Enter fullscreen mode Exit fullscreen mode

Features delivered: UC-05 Confirmation Email, UC-06 Cancellation Email.


10. The API Gateway — YARP

📁 src/ApiGateway/

YARP (Yet Another Reverse Proxy) acts as the single entry point for all client traffic. It validates JWTs, applies rate limiting, and routes to the correct downstream service — with zero business logic.

// src/ApiGateway/Program.cs

var builder = WebApplication.CreateBuilder(args);

builder.Services
    .AddReverseProxy()
    .LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"));

// Validate JWTs at the gateway edge — downstream services trust forwarded tokens
builder.Services.AddAuthentication("Bearer")
    .AddJwtBearer("Bearer", opts =>
    {
        opts.Authority = builder.Configuration["Identity:Authority"];
        opts.RequireHttpsMetadata = false;
    });

builder.Services.AddAuthorization(opts =>
    opts.AddPolicy("default", p => p.RequireAuthenticatedUser()));

// 300 requests/minute per client — protects downstream services
builder.Services.AddRateLimiter(opts =>
    opts.AddFixedWindowLimiter("api", cfg =>
    {
        cfg.Window      = TimeSpan.FromMinutes(1);
        cfg.PermitLimit = 300;
        cfg.QueueLimit  = 0;
    }));

var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.UseRateLimiter();
app.MapReverseProxy();
app.MapHealthChecks("/health");
app.Run();
Enter fullscreen mode Exit fullscreen mode
// src/ApiGateway/appsettings.json  declarative routing, no code changes needed

{
  "ReverseProxy": {
    "Routes": {
      "events-route": {
        "ClusterId": "eventcatalog",
        "Match": { "Path": "/api/events/{**catch-all}" }
      },
      "bookings-route": {
        "ClusterId": "booking",
        "Match": { "Path": "/api/bookings/{**catch-all}" },
        "AuthorizationPolicy": "default"
      },
      "payments-route": {
        "ClusterId": "payment",
        "Match": { "Path": "/api/payments/{**catch-all}" },
        "AuthorizationPolicy": "default"
      }
    },
    "Clusters": {
      "eventcatalog": {
        "Destinations": { "d1": { "Address": "http://eventcatalog:8080/" } }
      },
      "booking": {
        "Destinations": { "d1": { "Address": "http://booking:8080/" } }
      },
      "payment": {
        "Destinations": { "d1": { "Address": "http://payment:8080/" } }
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

11. Event-Driven Architecture Deep Dive

Why Two Kinds of Events?

The system uses two distinct event types with very different concerns:

Domain Event                          Integration Event
─────────────────────────────────     ──────────────────────────────────
In-process only                       Cross-service, via RabbitMQ
Synchronous, raised during tx         Asynchronous, published after tx
Rich domain type                      Minimal DTO (only what others need)
Cleared after SaveChanges             Persisted in outbox until delivered
Never leaves the aggregate's context  Public API of the bounded context
Enter fullscreen mode Exit fullscreen mode

Event Flow Diagram

┌────────────────────────────────────────────────────────────────────┐
│  Booking Service                                                   │
│                                                                    │
│  booking.Create() ──raises──▶ BookingCreatedEvent (domain)        │
│                                        │                           │
│  DbContext.SaveChangesAsync()          │                           │
│    ├── writes Booking row              │                           │
│    ├── converts domain event ──────────┘                           │
│    └── writes OutboxMessage row                                    │
│                    │ (same transaction — atomic)                   │
│                    │                                               │
│  OutboxProcessor   │                                               │
│    polls every 5s  │                                               │
│    reads unprocessed OutboxMessages                                │
│    publishes ──────▶ RabbitMQ ──▶ BookingCreatedIntegrationEvent  │
│    marks ProcessedOnUtc                                            │
└────────────────────────────────────────────────────────────────────┘

RabbitMQ
  Exchange: booking-created-integration-event
  Queue:    booking-saga (BookingSagaStateMachine)
  Queue:    notification (BookingConfirmedConsumer)
Enter fullscreen mode Exit fullscreen mode

MassTransit Consumer Registration

All consumers and sagas are auto-registered by endpoint name formatter — no manual queue configuration:

x.SetKebabCaseEndpointNameFormatter();
// BookingConfirmedConsumer → queue: booking-confirmed-consumer
// BookingSagaStateMachine  → queue: booking-saga-state-machine

x.UsingRabbitMq((ctx, cfg) =>
{
    cfg.Host("rabbitmq", "/", h =>
    {
        h.Username("ticketing");
        h.Password("ticketing_pass");
    });
    cfg.ConfigureEndpoints(ctx); // ← auto-creates all queues + bindings
});
Enter fullscreen mode Exit fullscreen mode

12. The Booking Saga — Distributed Workflow

📁 src/Services/Booking/Application/Sagas/BookingSagaStateMachine.cs

The saga orchestrates the multi-service checkout workflow. Its state is persisted to SQL Server so it survives process restarts.

State Machine Diagram

                    ┌─────────────────────────────────┐
                    │         BookingSagaState         │
                    │                                  │
  BookingCreated ──▶│  Initial ──▶ AwaitingPayment    │
                    │                 │                │
  PaymentProcessed  │        Success ─┤                │
  (Success = true) ▶│                 │──▶ Completed  │
                    │                 │                │
  PaymentProcessed  │        Failure ─┤                │
  (Success = false)▶│                 │──▶ Failed     │
                    └─────────────────────────────────┘

  On Completed: Publish IssueTicketCommand
                Publish BookingConfirmedIntegrationEvent

  On Failed:    Publish ReleaseSeatsCommand (compensate)
                Publish BookingCancelledIntegrationEvent
Enter fullscreen mode Exit fullscreen mode
// src/Services/Booking/Application/Sagas/BookingSagaStateMachine.cs

public class BookingSagaState : SagaStateMachineInstance
{
    public Guid CorrelationId { get; set; }   // = BookingId — ties all messages together
    public string CurrentState { get; set; } = null!;
    public Guid EventId { get; set; }
    public Guid UserId { get; set; }
    public string SeatNumber { get; set; } = null!;
    public decimal TotalAmount { get; set; }
    public string Currency { get; set; } = null!;
    public string? PaymentReference { get; set; }
    public string? FailureReason { get; set; }
    public DateTime CreatedAt { get; set; }
}

public class BookingSagaStateMachine : MassTransitStateMachine<BookingSagaState>
{
    public State AwaitingPayment { get; private set; } = null!;
    public State Completed { get; private set; } = null!;
    public State Failed { get; private set; } = null!;

    public Event<BookingCreatedIntegrationEvent> BookingCreated { get; private set; } = null!;
    public Event<PaymentProcessedEvent> PaymentCompleted { get; private set; } = null!;

    public BookingSagaStateMachine()
    {
        InstanceState(x => x.CurrentState);

        // Correlation: all events for one booking share the same BookingId
        Event(() => BookingCreated,
            x => x.CorrelateById(ctx => ctx.Message.BookingId));
        Event(() => PaymentCompleted,
            x => x.CorrelateById(ctx => ctx.Message.BookingId));

        Initially(
            When(BookingCreated)
                .Then(ctx =>
                {
                    // Store all data we'll need later in the saga state
                    ctx.Saga.EventId     = ctx.Message.EventId;
                    ctx.Saga.UserId      = ctx.Message.UserId;
                    ctx.Saga.SeatNumber  = ctx.Message.SeatNumber;
                    ctx.Saga.TotalAmount = ctx.Message.TotalAmount;
                    ctx.Saga.Currency    = ctx.Message.Currency;
                    ctx.Saga.CreatedAt   = DateTime.UtcNow;
                })
                // Command the Payment service to charge the customer
                .Publish(ctx => new ProcessPaymentCommand(
                    ctx.Saga.CorrelationId,
                    ctx.Saga.UserId,
                    ctx.Saga.TotalAmount,
                    ctx.Saga.Currency))
                .TransitionTo(AwaitingPayment));

        During(AwaitingPayment,

            // ── Happy path ─────────────────────────────────────────────────────
            When(PaymentCompleted, ctx => ctx.Message.Success)
                .Then(ctx => ctx.Saga.PaymentReference = ctx.Message.PaymentReference)
                // Tell EventCatalog to decrement available seats permanently
                .Publish(ctx => new IssueTicketCommand(
                    ctx.Saga.CorrelationId,
                    ctx.Saga.EventId,
                    ctx.Saga.UserId,
                    ctx.Saga.SeatNumber))
                // Notify all subscribers (Notification service sends email)
                .Publish(ctx => new BookingConfirmedIntegrationEvent(
                    ctx.Saga.CorrelationId,
                    ctx.Saga.UserId,
                    ctx.Saga.PaymentReference!))
                .TransitionTo(Completed)
                .Finalize(),

            // ── Compensating path — payment failed ─────────────────────────────
            When(PaymentCompleted, ctx => !ctx.Message.Success)
                .Then(ctx => ctx.Saga.FailureReason = ctx.Message.FailureReason)
                // Release the Redis seat lock so others can book
                .Publish(ctx => new ReleaseSeatsCommand(
                    ctx.Saga.EventId, ctx.Saga.SeatNumber))
                // Notify all subscribers (Notification sends failure email)
                .Publish(ctx => new BookingCancelledIntegrationEvent(
                    ctx.Saga.CorrelationId,
                    ctx.Saga.EventId,
                    ctx.Saga.SeatNumber,
                    ctx.Saga.FailureReason ?? "Payment failed"))
                .TransitionTo(Failed)
                .Finalize());

        SetCompletedWhenFinalized();
    }
}
Enter fullscreen mode Exit fullscreen mode

13. The Outbox Pattern — Guaranteed Delivery

Without the outbox, there is a window between saving the booking to the database and publishing the event to RabbitMQ where a crash loses the message permanently. The outbox closes that window.

// src/SharedKernel/Infrastructure/OutboxMessage.cs

public class OutboxMessage
{
    public Guid Id { get; set; } = Guid.NewGuid();
    public string EventType { get; set; } = null!;     // full CLR type name
    public string Payload { get; set; } = null!;       // JSON-serialised event
    public DateTime OccurredOnUtc { get; set; } = DateTime.UtcNow;
    public DateTime? ProcessedOnUtc { get; set; }      // null = not yet published
    public string? Error { get; set; }

    [Timestamp]   // SQL Server ROWVERSION — optimistic concurrency for processor
    public byte[] RowVersion { get; set; } = null!;
}
Enter fullscreen mode Exit fullscreen mode

The flow in sequence:

1. Command handler calls _uow.SaveChangesAsync()
        │
        ├── EF writes Booking row          ─┐
        └── EF writes OutboxMessage row    ─┘  one ACID transaction

2. OutboxProcessor (IHostedService) polls every 5 seconds:
        SELECT TOP 10 * FROM OutboxMessages
        WHERE ProcessedOnUtc IS NULL
        ORDER BY OccurredOnUtc

3. For each message:
        a. Deserialize Payload to the event type
        b. Publish to RabbitMQ via IPublishEndpoint
        c. UPDATE OutboxMessages SET ProcessedOnUtc = GETUTCDATE()
               WHERE Id = @id AND RowVersion = @rowVersion
           (optimistic concurrency — prevents double-publish if two instances race)

4. If RabbitMQ is down:
        OutboxProcessor retries on next poll cycle
        Booking data is safe — it was committed in step 1
Enter fullscreen mode Exit fullscreen mode

This pattern guarantees at-least-once delivery. Consumers must be idempotent (or use an inbox table to deduplicate).


14. Redis Seat Locking — Preventing Overselling

// src/Services/Booking/Infrastructure/RedisSeatLockService.cs

public class RedisSeatLockService : ISeatLockService
{
    private readonly IDatabase _redis;
    public RedisSeatLockService(IConnectionMultiplexer redis) =>
        _redis = redis.GetDatabase();

    public async Task<bool> TryLockAsync(
        Guid eventId, string seat, TimeSpan ttl, CancellationToken ct = default)
    {
        // Redis SET key value NX EX seconds
        // NX = only set if key does NOT exist (atomic test-and-set)
        // Returns true  = lock acquired (key was not present)
        // Returns false = lock NOT acquired (another user holds it)
        var key = $"seat-lock:{eventId}:{seat}";
        return await _redis.StringSetAsync(key, "locked", ttl, When.NotExists);
    }

    public async Task ReleaseAsync(Guid eventId, string seat, CancellationToken ct = default)
    {
        var key = $"seat-lock:{eventId}:{seat}";
        await _redis.KeyDeleteAsync(key);
    }
}
Enter fullscreen mode Exit fullscreen mode

Why Redis instead of a database lock?

Approach Throughput Scope TTL support
SQL SELECT FOR UPDATE Low (row lock, connection held) Single DB No
Optimistic concurrency Medium (retries on conflict) Single DB No
Redis SET NX Very high (microsecond op, connection pool) Distributed ✅ Yes

The TTL (10 minutes) acts as a safety valve — if a user abandons checkout or their browser crashes, the seat is automatically available again after 10 minutes. No scheduled cleanup job needed.


15. The Angular Frontend — NgRx Architecture

📁 frontend/src/app/

The frontend follows the Redux pattern via NgRx: all state is in the store, all side effects (HTTP calls, navigation) are in Effects, all views are driven by Selectors.

Application Bootstrap

// frontend/src/app/app.config.ts

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes),
    provideHttpClient(withInterceptors([authInterceptor, errorInterceptor])),
    // NgRx store slices
    provideStore({
      events:  eventsReducer,
      booking: bookingReducer,
      auth:    authReducer
    }),
    provideEffects([EventsEffects, BookingEffects, AuthEffects]),
    provideRouterStore(),
    provideStoreDevtools({ maxAge: 25 }),
    importProvidersFrom(OAuthModule.forRoot()),
  ]
};
Enter fullscreen mode Exit fullscreen mode

Lazy-Loaded Feature Routes

// frontend/src/app/app.routes.ts

export const routes: Routes = [
  { path: '',       redirectTo: 'events', pathMatch: 'full' },
  {
    path: 'events',
    loadChildren: () => import('./features/events/events.routes')
      .then(m => m.EVENTS_ROUTES)
    // No auth required — browse without login
  },
  {
    path: 'booking',
    loadChildren: () => import('./features/booking/booking.routes')
      .then(m => m.BOOKING_ROUTES),
    canActivate: [authGuard]  // JWT required
  },
  {
    path: 'account',
    loadChildren: () => import('./features/account/account.routes')
      .then(m => m.ACCOUNT_ROUTES),
    canActivate: [authGuard]
  },
  {
    path: 'admin',
    loadChildren: () => import('./features/admin/admin.routes')
      .then(m => m.ADMIN_ROUTES),
    canActivate: [authGuard],
    data: { roles: ['Admin'] }
  },
];
Enter fullscreen mode Exit fullscreen mode

NgRx Booking Store

// frontend/src/app/store/booking/booking.reducer.ts

export interface BookingState {
  currentBookingId: string | null;
  myBookings: Booking[];
  loading: boolean;
  error: string | null;
}

export const BookingActions = {
  createBooking: createAction('[Booking] Create',
    props<{ eventId: string; seatNumber: string; totalAmount: number }>()),
  createBookingSuccess: createAction('[Booking] Create Success',
    props<{ bookingId: string }>()),
  createBookingFailure: createAction('[Booking] Create Failure',
    props<{ error: string }>()),
  loadMyBookings: createAction('[Booking] Load My Bookings',
    props<{ userId: string }>()),
  loadMyBookingsSuccess: createAction('[Booking] Load My Bookings Success',
    props<{ bookings: Booking[] }>()),
};

// Pure reducer — no side effects, easy to test
export const bookingReducer = createReducer(initialState,
  on(BookingActions.createBooking,
    s => ({ ...s, loading: true, error: null })),
  on(BookingActions.createBookingSuccess,
    (s, { bookingId }) => ({ ...s, currentBookingId: bookingId, loading: false })),
  on(BookingActions.createBookingFailure,
    (s, { error }) => ({ ...s, loading: false, error })),
  on(BookingActions.loadMyBookingsSuccess,
    (s, { bookings }) => ({ ...s, myBookings: bookings })),
);
Enter fullscreen mode Exit fullscreen mode

Effects — HTTP Side Effects

@Injectable()
export class BookingEffects {

  createBooking$ = createEffect(() => this.actions$.pipe(
    ofType(BookingActions.createBooking),
    switchMap(({ eventId, seatNumber, totalAmount }) =>
      this.http.post<{ id: string }>(`${environment.apiUrl}/api/bookings`, {
        eventId, seatNumber, totalAmount, currency: 'USD'
      }).pipe(
        map(res  => BookingActions.createBookingSuccess({ bookingId: res.id })),
        catchError(err => of(BookingActions.createBookingFailure({ error: err.message })))
      )
    )
  ));

  loadMyBookings$ = createEffect(() => this.actions$.pipe(
    ofType(BookingActions.loadMyBookings),
    switchMap(({ userId }) =>
      this.http.get<Booking[]>(`${environment.apiUrl}/api/bookings/user/${userId}`).pipe(
        map(bookings => BookingActions.loadMyBookingsSuccess({ bookings })),
        catchError(() => of(BookingActions.loadMyBookingsSuccess({ bookings: [] })))
      )
    )
  ));

  constructor(private actions$: Actions, private http: HttpClient) {}
}
Enter fullscreen mode Exit fullscreen mode

Auth Guard & Interceptor

// frontend/src/app/core/guards/auth.guard.ts

export const authGuard: CanActivateFn = () => {
  const auth   = inject(AuthService);
  const router = inject(Router);
  if (auth.isLoggedIn) return true;
  auth.login();   // redirect to IdentityServer PKCE flow
  return false;
};

// frontend/src/app/core/interceptors/auth.interceptor.ts

export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const auth = inject(AuthService);
  if (auth.accessToken) {
    req = req.clone({
      setHeaders: { Authorization: `Bearer ${auth.accessToken}` }
    });
  }
  return next(req);
};

export const errorInterceptor: HttpInterceptorFn = (req, next) =>
  next(req).pipe(
    catchError(err => {
      if (err.status === 401) inject(AuthService).login();
      return throwError(() => err);
    })
  );
Enter fullscreen mode Exit fullscreen mode

Event List Component — Search & Browse

// frontend/src/app/features/events/event-list.component.ts

@Component({
  selector: 'app-event-list',
  standalone: true,
  imports: [CommonModule, ReactiveFormsModule, RouterModule],
  template: `
    <div class="events-container">
      <h1>Upcoming Events</h1>

      <form [formGroup]="filterForm" (ngSubmit)="search()" class="filters">
        <input formControlName="city"     placeholder="City" />
        <select formControlName="category">
          <option value="">All categories</option>
          <option value="Concert">Concert</option>
          <option value="Sports">Sports</option>
          <option value="Theater">Theater</option>
        </select>
        <input formControlName="from" type="date" />
        <button type="submit">Search</button>
      </form>

      <div *ngIf="loading$ | async" class="loading">Loading events...</div>

      <div class="event-grid">
        <div *ngFor="let event of events$ | async" class="event-card">
          <div class="event-category">{{ event.category }}</div>
          <h2>{{ event.title }}</h2>
          <p class="event-meta">
            📍 {{ event.venue }}, {{ event.city }}<br/>
            📅 {{ event.startsAtUtc | date:'medium' }}
          </p>
          <div class="event-footer">
            <span class="price">
              From {{ event.basePrice | currency:event.currency }}
            </span>
            <span class="seats">{{ event.availableSeats }} seats left</span>
            <a [routerLink]="['/events', event.id]" class="btn-primary">
              View Tickets
            </a>
          </div>
        </div>
      </div>
    </div>
  `
})
export class EventListComponent implements OnInit {
  events$  = this.store.select(selectAllEvents);
  loading$ = this.store.select(selectEventsLoading);
  filterForm = this.fb.group({ city: [''], category: [''], from: [''] });

  constructor(private store: Store, private fb: FormBuilder) {}

  ngOnInit() { this.search(); }

  search() {
    const { city, category, from } = this.filterForm.value;
    this.store.dispatch(EventActions.search({
      city:     city     || undefined,
      category: category || undefined,
      from:     from     || undefined,
    }));
  }
}
Enter fullscreen mode Exit fullscreen mode

Admin Event Management Component

// frontend/src/app/features/admin/admin-events.component.ts (excerpt)

export class AdminEventsComponent implements OnInit {
  createForm = this.fb.group({
    title:       ['', [Validators.required, Validators.maxLength(200)]],
    description: ['', Validators.required],
    category:    ['Concert', Validators.required],
    venue:       ['', Validators.required],
    city:        ['', Validators.required],
    startsAtUtc: ['', Validators.required],
    basePrice:   [0,  [Validators.required, Validators.min(0)]],
    currency:    ['USD', [Validators.required, Validators.minLength(3)]],
    totalSeats:  [100, [Validators.required, Validators.min(1)]],
  });

  createEvent() {
    if (this.createForm.invalid) return;
    this.saving = true;
    this.http.post(`${environment.apiUrl}/api/events`, this.createForm.value)
      .subscribe({
        next: () => {
          this.successMessage = 'Event created!';
          this.loadEvents();
          this.saving = false;
        },
        error: err => {
          this.errorMessage = err.error?.message ?? 'Failed to create event';
          this.saving = false;
        }
      });
  }

  publishEvent(id: string) {
    this.http.post(`${environment.apiUrl}/api/events/${id}/publish`, {})
      .subscribe({ next: () => this.loadEvents() });
  }
}
Enter fullscreen mode Exit fullscreen mode

16. All Features & Use Cases — End to End

Feature Matrix

# Feature Actor Services involved Pattern
UC-01 Browse & search events Anonymous EventCatalog Query
UC-02 View event details & seat map Anonymous EventCatalog Query
UC-03 Reserve a seat User Booking, Redis Command + Redis Lock
UC-04 Process payment System Payment → Booking Event Consumer
UC-05 Confirm booking & issue ticket System Booking, Notification Saga + Event
UC-06 Cancel on payment failure System Booking, EventCatalog, Notification Saga + Compensation
UC-07 View my ticket history User Booking Query
UC-08 Admin create event (draft) Admin EventCatalog Command
UC-09 Admin publish event Admin EventCatalog Command
UC-10 Session abandonment / timeout System Redis TTL auto-release
UC-11 Rate limiting System ApiGateway Fixed window 300/min
UC-12 Token refresh User Identity, Angular Silent refresh OIDC

UC-01 & UC-02: Browse & View Events

GET /api/events?city=London&category=Concert&page=1
          │
API Gateway (no auth required for this route)
          │
EventCatalog: SearchEventsQuery dispatched via MediatR
          │
ValidationBehaviour → LoggingBehaviour → SearchEventsHandler
          │
EF Core: SELECT * FROM Events WHERE IsPublished=1
         AND City='London' AND Category='Concert'
         ORDER BY StartsAtUtc
         OFFSET 0 ROWS FETCH NEXT 20 ROWS ONLY
          │
Returns List<EventDto> → 200 OK
Enter fullscreen mode Exit fullscreen mode

UC-03: Reserve a Seat (Full Journey)

POST /api/bookings
{ "eventId": "...", "seatNumber": "A12", "totalAmount": 99.99 }
          │
API Gateway: JWT validated → route to Booking:8080
          │
BookingsController.Create()
          │
MediatR: CreateBookingCommand dispatched
          │
ValidationBehaviour: all fields validated
LoggingBehaviour: "Handling CreateBookingCommand" logged
          │
CreateBookingCommandHandler:
  1. Redis: SETNX seat-lock:{eventId}:A12 "locked" EX 600
       → true: lock acquired, continue
       → false: throw "Seat held by another user" → 409 Conflict
  2. Booking.Create() → Status=Pending, raises BookingCreatedEvent
  3. _repo.Add(booking)
  4. _uow.SaveChangesAsync():
       ├── INSERT INTO Bookings ...
       └── INSERT INTO OutboxMessages (EventType, Payload, OccurredOnUtc)
           (same transaction)
  5. Return booking.Id → 201 Created

Background (OutboxProcessor polls every 5 seconds):
  SELECT TOP 10 FROM OutboxMessages WHERE ProcessedOnUtc IS NULL
  → Deserialize BookingCreatedEvent
  → Publish BookingCreatedIntegrationEvent to RabbitMQ
  → UPDATE OutboxMessages SET ProcessedOnUtc = NOW()

RabbitMQ → BookingSagaStateMachine:
  Initially: receives BookingCreated
  → stores saga state to SQL Server
  → publishes ProcessPaymentCommand
  → transitions to AwaitingPayment
Enter fullscreen mode Exit fullscreen mode

UC-04 & UC-05: Payment Processing & Confirmation

RabbitMQ → ProcessPaymentConsumer (Payment Service):
  Payment.Create(bookingId, userId, amount, currency)
  → StripeService.ChargeAsync(99.99, "USD")
  → payment.Succeed("pi_3Nxxxx")
  → INSERT INTO Payments ...
  → Publish PaymentProcessedEvent(Success=true, PspReference="pi_3Nxxxx")

RabbitMQ → BookingSagaStateMachine:
  During AwaitingPayment, PaymentCompleted(Success=true):
  → saga.PaymentReference = "pi_3Nxxxx"
  → Publish IssueTicketCommand (EventCatalog decrements AvailableSeats)
  → Publish BookingConfirmedIntegrationEvent
  → Transition to Completed → Finalize

RabbitMQ → BookingConfirmedConsumer (Notification Service):
  Receive BookingConfirmedIntegrationEvent
  → IEmailService.SendAsync(
       to: "user@example.com",
       subject: "Your ticket is confirmed! 🎟️",
       body: "Booking abc123 confirmed. Payment: pi_3Nxxxx")
Enter fullscreen mode Exit fullscreen mode

UC-06: Payment Failure & Compensation

RabbitMQ → ProcessPaymentConsumer (Payment Service):
  payment.Fail("Insufficient funds")
  → INSERT INTO Payments (Status='Failed', FailureReason='...')
  → Publish PaymentProcessedEvent(Success=false, FailureReason="Insufficient funds")

RabbitMQ → BookingSagaStateMachine:
  During AwaitingPayment, PaymentCompleted(Success=false):
  → Publish ReleaseSeatsCommand:
       Redis DEL seat-lock:{eventId}:A12  ← seat available again
       EventCatalog: event.ReleaseSeat()   ← AvailableSeats++
  → Publish BookingCancelledIntegrationEvent
  → Transition to Failed → Finalize

RabbitMQ → BookingCancelledConsumer (Notification Service):
  Receive BookingCancelledIntegrationEvent
  → IEmailService.SendAsync(
       subject: "Booking cancelled",
       body: "Your booking was cancelled. Reason: Insufficient funds.
              Seat A12 has been released.")
Enter fullscreen mode Exit fullscreen mode

UC-07: View My Ticket History

GET /api/bookings/user/{userId}
          │
API Gateway: JWT validated
          │
BookingsController.GetByUser()
          │
MediatR: GetBookingsByUserQuery
          │
GetBookingsByUserHandler:
  EF Core: SELECT * FROM Bookings WHERE UserId = @userId
          │
Returns List<BookingDto> with status, seat, amount, timestamps
Enter fullscreen mode Exit fullscreen mode

UC-08 & UC-09: Admin Creates & Publishes Event

POST /api/events (requires Admin role in JWT)
{ "title": "Coldplay Live", "category": "Concert", "totalSeats": 500 ... }
          │
EventsController: [Authorize(Roles = "Admin")]
          │
MediatR: CreateEventCommand
          │
CreateEventValidator: all fields validated
          │
CreateEventHandler:
  Event.Create(...)  → IsPublished=false (always draft)
  → INSERT INTO Events ...
          │
Returns 201 Created with event.Id

POST /api/events/{id}/publish (Admin only)
          │
PublishEventCommand
          │
event.Publish() → IsPublished=true
          │
Event now appears in search results (WHERE IsPublished=1)
Enter fullscreen mode Exit fullscreen mode

UC-10: Session Abandonment / Timeout

User starts checkout → seat locked in Redis (TTL: 10 minutes)
User closes browser / session expires
          │
Redis TTL expires automatically after 10 minutes:
  DEL seat-lock:{eventId}:{seat}
          │
No cleanup job needed.
Seat becomes available again for the next customer.

If payment was already initiated (saga in AwaitingPayment):
  → Saga timeout (configurable) triggers compensation
  → ReleaseSeatsCommand published
  → BookingCancelledIntegrationEvent published
Enter fullscreen mode Exit fullscreen mode

UC-11: Rate Limiting (Gateway)

Client sends >300 requests/minute to any /api/** route
          │
YARP RateLimiter middleware:
  AddFixedWindowLimiter("api", window=1min, permitLimit=300)
          │
→ 429 Too Many Requests returned immediately
  Downstream services are protected — they never see the request
Enter fullscreen mode Exit fullscreen mode

UC-12: Token Refresh (Silent OIDC)

Angular SPA: access token approaching expiry
          │
angular-oauth2-oidc: setupAutomaticSilentRefresh()
  → silent iframe to Identity service
  → Identity issues new access token via refresh flow
  → OAuthService.accessToken updated
          │
Next HTTP request automatically carries fresh token via authInterceptor
No user interaction required
Enter fullscreen mode Exit fullscreen mode

17. Infrastructure: Docker Compose & SQL Server

📁 docker-compose.yml

name: ticketing-system

services:
  sqlserver:
    image: mcr.microsoft.com/mssql/server:2022-latest
    environment:
      ACCEPT_EULA: "Y"
      MSSQL_SA_PASSWORD: "${SA_PASSWORD}"
    ports: ["1433:1433"]
    volumes: [sqlserver_data:/var/opt/mssql]
    healthcheck:
      test: ["CMD", "/opt/mssql-tools18/bin/sqlcmd",
             "-S", "localhost", "-U", "sa", "-P", "${SA_PASSWORD}",
             "-Q", "SELECT 1", "-No"]
      interval: 15s
      retries: 10
      start_period: 30s    # MSSQL needs time to initialise

  rabbitmq:
    image: rabbitmq:3.13-management-alpine
    environment:
      RABBITMQ_DEFAULT_USER: "${RABBITMQ_USER}"
      RABBITMQ_DEFAULT_PASS: "${RABBITMQ_PASS}"
    ports:
      - "5672:5672"
      - "15672:15672"      # Management UI → http://localhost:15672
    healthcheck:
      test: ["CMD", "rabbitmq-diagnostics", "ping"]
      interval: 15s
      retries: 10

  redis:
    image: redis:7-alpine
    command: redis-server --requirepass ${REDIS_PASSWORD}
    ports: ["6379:6379"]

  jaeger:
    image: jaegertracing/all-in-one:latest
    ports:
      - "16686:16686"      # Jaeger UI → http://localhost:16686
      - "4317:4317"        # OTLP gRPC

  booking:
    build:
      context: .
      dockerfile: src/Services/Booking/Dockerfile
    environment:
      ASPNETCORE_URLS: http://+:8080
      ConnectionStrings__SqlServer: >
        Server=sqlserver,1433;Database=TicketingSystem_Booking;
        User Id=sa;Password=${SA_PASSWORD};TrustServerCertificate=true
      ConnectionStrings__Redis: "redis:6379,password=${REDIS_PASSWORD}"
      RabbitMQ__Host: rabbitmq
      RabbitMQ__Username: "${RABBITMQ_USER}"
      RabbitMQ__Password: "${RABBITMQ_PASS}"
    ports: ["5003:8080"]
    depends_on:
      sqlserver: { condition: service_healthy }
      rabbitmq:  { condition: service_healthy }
      redis:     { condition: service_healthy }

  # ... identity, eventcatalog, payment, notification, gateway, frontend
  # Full file: https://github.com/naimulkarim/TicketingSystem/blob/main/docker-compose.yml

volumes:
  sqlserver_data:
  rabbitmq_data:
  redis_data:
Enter fullscreen mode Exit fullscreen mode

Local endpoint reference:

Service URL Notes
Frontend SPA http://localhost:4200 Angular
API Gateway http://localhost:5000 All API calls go here
Identity http://localhost:5001 OIDC discovery at /.well-known/openid-configuration
Event Catalog http://localhost:5002 Swagger at /swagger
Booking http://localhost:5003 Swagger at /swagger
Payment http://localhost:5004 Internal only
Notification http://localhost:5005 Internal only
RabbitMQ UI http://localhost:15672 user: ticketing / ticketing_pass
Jaeger UI http://localhost:16686 Distributed traces

18. CI/CD: GitHub Actions Pipeline

📁 .github/workflows/ci.yml

name: CI

on:
  push:         { branches: [main, develop] }
  pull_request: { branches: [main] }

jobs:
  dotnet:
    name: .NET Build & Test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v4
        with: { dotnet-version: "8.0.x" }
      - run: dotnet restore TicketingSystem.sln
      - run: dotnet build TicketingSystem.sln --no-restore -c Release
      - run: |
          dotnet test tests/Unit/ -c Release \
            --logger "trx;LogFileName=unit-results.trx" \
            --collect:"XPlat Code Coverage"
      - uses: actions/upload-artifact@v4
        if: always()
        with: { name: unit-test-results, path: "**/*.trx" }

  angular:
    name: Angular Build
    runs-on: ubuntu-latest
    defaults: { run: { working-directory: frontend } }
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: "20.x", cache: npm,
                cache-dependency-path: frontend/package-lock.json }
      - run: npm ci
      - run: npm run lint || true
      - run: npm run build      # production build: tree-shaking + strict types

  docker:
    name: Docker Image Build
    needs: [dotnet, angular]
    runs-on: ubuntu-latest
    strategy:
      matrix:
        service:
          - { name: identity,     dockerfile: src/Services/Identity/Dockerfile }
          - { name: eventcatalog, dockerfile: src/Services/EventCatalog/Dockerfile }
          - { name: booking,      dockerfile: src/Services/Booking/Dockerfile }
          - { name: payment,      dockerfile: src/Services/Payment/Dockerfile }
          - { name: notification, dockerfile: src/Services/Notification/Dockerfile }
          - { name: gateway,      dockerfile: src/ApiGateway/Dockerfile }
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/build-push-action@v5
        with:
          context: .
          file: ${{ matrix.service.dockerfile }}
          push: false
          tags: ticketing/${{ matrix.service.name }}:ci
          cache-from: type=gha    # GitHub Actions layer cache
          cache-to: type=gha,mode=max
Enter fullscreen mode Exit fullscreen mode

The pipeline runs 3 parallel jobs. docker waits for both dotnet and angular to pass. All 6 service images are built simultaneously in the matrix — if any Dockerfile is broken, the pipeline fails before anything reaches production.


19. Unit Tests

📁 tests/Unit/

Stack: xunit · FluentAssertions · NSubstitute

Aggregate Invariant Tests

// tests/Unit/Booking/BookingAggregateTests.cs

public class BookingAggregateTests
{
    [Fact]
    public void Create_ShouldSetPendingStatus()
    {
        var booking = MakeBooking();
        booking.Status.Should().Be(BookingStatus.Pending);
    }

    [Fact]
    public void Create_ShouldRaiseBookingCreatedEvent()
    {
        var booking = MakeBooking();
        booking.DomainEvents.Should().ContainSingle()
            .Which.Should().BeOfType<BookingCreatedEvent>();
    }

    [Fact]
    public void Confirm_WhenPending_ShouldSetConfirmedStatus()
    {
        var booking = MakeBooking();
        booking.Confirm("pi_123");
        booking.Status.Should().Be(BookingStatus.Confirmed);
    }

    [Fact]
    public void Confirm_WhenAlreadyConfirmed_ShouldThrow()
    {
        var booking = MakeBooking();
        booking.Confirm("pi_123");
        var act = () => booking.Confirm("pi_456");
        act.Should().Throw<InvalidOperationException>();
    }

    [Fact]
    public void Cancel_WhenPending_ShouldRaiseBookingCancelledEvent()
    {
        var booking = MakeBooking();
        booking.Cancel("Payment failed");
        booking.DomainEvents.Should()
            .Contain(e => e is BookingCancelledEvent);
    }

    [Fact]
    public void Cancel_WhenAlreadyCancelled_ShouldThrow()
    {
        var booking = MakeBooking();
        booking.Cancel("first");
        var act = () => booking.Cancel("second");
        act.Should().Throw<InvalidOperationException>();
    }

    [Fact]
    public void ClearDomainEvents_ShouldRemoveAll()
    {
        var booking = MakeBooking();
        booking.Confirm("pi_x");
        booking.ClearDomainEvents();
        booking.DomainEvents.Should().BeEmpty();
    }

    private static Booking MakeBooking() =>
        Booking.Create(Guid.NewGuid(), Guid.NewGuid(), "A1", 99.99m, "USD");
}
Enter fullscreen mode Exit fullscreen mode

Command Handler Tests with Mocks

// tests/Unit/Booking/CreateBookingCommandHandlerTests.cs

public class CreateBookingCommandHandlerTests
{
    private readonly IBookingRepository _repo    = Substitute.For<IBookingRepository>();
    private readonly IUnitOfWork        _uow     = Substitute.For<IUnitOfWork>();
    private readonly ISeatLockService   _lock    = Substitute.For<ISeatLockService>();

    [Fact]
    public async Task Handle_WhenSeatAvailable_ShouldAddBookingAndReturnId()
    {
        _lock.TryLockAsync(Arg.Any<Guid>(), Arg.Any<string>(),
            Arg.Any<TimeSpan>(), Arg.Any<CancellationToken>())
            .Returns(true);

        var handler = new CreateBookingCommandHandler(_repo, _uow, _lock);
        var cmd = new CreateBookingCommand(Guid.NewGuid(), Guid.NewGuid(), "B5", 50m);

        var id = await handler.Handle(cmd, CancellationToken.None);

        id.Should().NotBeEmpty();
        _repo.Received(1).Add(Arg.Any<Booking>());
        await _uow.Received(1).SaveChangesAsync(Arg.Any<CancellationToken>());
    }

    [Fact]
    public async Task Handle_WhenSeatLocked_ShouldThrowAndNotPersist()
    {
        _lock.TryLockAsync(Arg.Any<Guid>(), Arg.Any<string>(),
            Arg.Any<TimeSpan>(), Arg.Any<CancellationToken>())
            .Returns(false);

        var handler = new CreateBookingCommandHandler(_repo, _uow, _lock);
        var cmd = new CreateBookingCommand(Guid.NewGuid(), Guid.NewGuid(), "C3", 75m);

        var act = async () => await handler.Handle(cmd, CancellationToken.None);

        await act.Should().ThrowAsync<InvalidOperationException>()
            .WithMessage("*held by another user*");

        _repo.DidNotReceive().Add(Arg.Any<Booking>());
        await _uow.DidNotReceive().SaveChangesAsync(Arg.Any<CancellationToken>());
    }
}
Enter fullscreen mode Exit fullscreen mode

Value Object Tests

// tests/Unit/SharedKernel/ValueObjectTests.cs

public class ValueObjectTests
{
    [Fact]
    public void TwoMoneyObjects_WithSameValues_ShouldBeEqual()
    {
        var a = new Money(99.99m, "USD");
        var b = new Money(99.99m, "USD");
        a.Should().Be(b);
        (a == b).Should().BeTrue();
    }

    [Fact]
    public void TwoMoneyObjects_WithDifferentAmounts_ShouldNotBeEqual()
    {
        var a = new Money(10m, "USD");
        var b = new Money(20m, "USD");
        a.Should().NotBe(b);
    }

    [Fact]
    public void Money_WithNegativeAmount_ShouldThrow()
    {
        var act = () => new Money(-1m, "USD");
        act.Should().Throw<ArgumentException>();
    }
}
Enter fullscreen mode Exit fullscreen mode

20. What to Build Next

Event Sourcing on the Booking aggregate — instead of storing current state, store every domain event that led to it (BookingCreated, BookingConfirmed, etc.). This provides a complete immutable audit log and the ability to replay history to any point in time.

Dedicated read model — currently queries hit the same SQL Server write database. For high read traffic (event search, seat availability), a separate read store updated by RabbitMQ projectors would scale reads independently of writes.

Kubernetes Helm charts — the Docker Compose configuration translates directly to Helm. Each service gets a Deployment, HorizontalPodAutoscaler, Service, and Ingress. Cert-manager handles TLS automatically.

Pact contract tests — integration events are the public API between services. Pact consumer-driven contract tests verify that producer events always satisfy what consumers expect, catching breaking changes before they reach the main branch.

Inbox deduplication — the outbox guarantees at-least-once delivery. The natural complement is an Inbox table per consumer that records received message IDs and skips duplicates, achieving exactly-once processing semantics.

Real Stripe integration — replace StripeService stub with Stripe.net SDK, add webhook endpoint for async payment confirmation, handle 3DS authentication flows.


21. Conclusion & GitHub Link

DDD, Event-Driven Architecture, and CQRS answer three distinct problems that every non-trivial distributed system eventually faces:

DDD answers: where does business logic live, and how do we stop it from rotting? — in aggregates, expressed in the language of the domain, protected by invariants that can never be violated regardless of which service or layer calls into them.

CQRS answers: how do we decouple the complexity of writes from the performance demands of reads? — commands own the write path with full domain model validation; queries own the read path with optimised projections, neither touching the other.

Event-Driven Architecture answers: how do loosely-coupled services coordinate without becoming entangled? — through immutable events on a message bus, with the booking saga orchestrating multi-service workflows, the outbox pattern ensuring zero message loss, and Redis seat locking preventing the fundamental overselling race condition.

Together these patterns produce a system where:

  • Each service can be deployed, scaled, and tested independently
  • Business invariants are impossible to violate by accident
  • Failures are handled by design, not by hope
  • The codebase reads like the domain it models

🚀 Get the Full Source Code

GitHub Repository: github.com/naimulkarim/TicketingSystem

# Clone and run the full stack locally
git clone https://github.com/naimulkarim/TicketingSystem.git
cd TicketingSystem
cp .env.example .env       # add your secrets
docker compose up --build  # starts everything
# Open http://localhost:4200
Enter fullscreen mode Exit fullscreen mode

Direct file links referenced in this article:

File Link
SharedKernel — AggregateRoot src/SharedKernel/Domain/AggregateRoot.cs
SharedKernel — Primitives src/SharedKernel/Domain/Primitives.cs
SharedKernel — Pipeline Behaviours src/SharedKernel/Application/Behaviours/PipelineBehaviours.cs
Identity Config src/Services/Identity/Api/IdentityConfig.cs
Event Aggregate src/Services/EventCatalog/Domain/Aggregates/Event.cs
Booking Aggregate src/Services/Booking/Domain/Aggregates/Booking.cs
Booking Events src/Services/Booking/Domain/Events/BookingEvents.cs
CreateBooking Command src/Services/Booking/Application/Commands/CreateBookingCommand.cs
Booking Queries src/Services/Booking/Application/Queries/GetBookingQuery.cs
Booking Saga src/Services/Booking/Application/Sagas/BookingSagaStateMachine.cs
Booking DbContext src/Services/Booking/Infrastructure/Persistence/BookingDbContext.cs
Redis Seat Lock src/Services/Booking/Infrastructure/RedisSeatLockService.cs
Booking Controller src/Services/Booking/Api/Controllers/BookingsController.cs
Payment Aggregate src/Services/Payment/Domain/Aggregates/Payment.cs
Payment Consumer src/Services/Payment/Infrastructure/Messaging/ProcessPaymentConsumer.cs
Notification Consumers src/Services/Notification/Application/Consumers/BookingConsumers.cs
API Gateway Program src/ApiGateway/Program.cs
Outbox Message src/SharedKernel/Infrastructure/OutboxMessage.cs
Auth Service (Angular) frontend/src/app/core/services/auth.service.ts
NgRx Booking Store frontend/src/app/store/booking/booking.reducer.ts
Docker Compose docker-compose.yml
GitHub Actions CI .github/workflows/ci.yml
Booking Aggregate Tests tests/Unit/Booking/BookingAggregateTests.cs

If this article helped you, leave a comment — I'm happy to go deeper on any section.

Tags: dotnet csharp architecture ddd eventdriven cqrs angular microservices rabbitmq masstransit designpatterns cleanarchitecture

Top comments (0)