pkey
.Net

Enterprise Architecture with Angular, Web API, MSSQL, CQRS and RabbitMQ

Introduction

In enterprise systems, the biggest problem is not code quality — it is architecture erosion over time.

Teams start with simple CRUD APIs. Months later:

  • controllers contain business logic
  • database leaks everywhere
  • reads and writes are tightly coupled
  • scaling becomes impossible
  • integration with other systems becomes fragile

This article presents a clean, scalable baseline architecture that works for:

  • industrial systems (PLC, SCADA integration)
  • enterprise SaaS platforms
  • ERP/MES integrations
  • high-load backend systems

The stack:

  • Angular (UI)
  • ASP.NET Core Web API (.NET backend)
  • Microsoft SQL Server (transactional storage)
  • CQRS (separation of reads/writes)
  • RabbitMQ (integration & async processing)

This is not tied to a specific application. It is a generic enterprise architecture blueprint.


Architecture First (Before Code)

High-Level Structure

[ Angular SPA ]

[ API Gateway / Web API ]

[ Application Layer (CQRS) ]

[ Domain Layer (DDD) ]

[ Infrastructure ]
├── MSSQL (Write DB)
├── Read Models
├── RabbitMQ
└── Cache

Core Architectural Decisions

1. Separation of Concerns

  • UI → presentation only
  • API → transport layer
  • Application → orchestration
  • Domain → business logic
  • Infrastructure → technical concerns

2. CQRS Split

OperationLayer
Commands (write)Domain + Transaction
Queries (read)Optimized projections

3. Async Integration via Messaging

Instead of:

Order → directly call Inventory API

We do:

Order → publish event → RabbitMQ → Inventory Service

This removes tight coupling.


4. Transactional Safety

Use Outbox Pattern:

Save Order + Save Event → SAME TRANSACTION
Publish later → background worker

Backend Implementation (.NET)

Solution Structure

/src
├── Api
├── Application
├── Domain
├── Infrastructure
└── Worker (Outbox Processor)

Domain Layer (DDD Core)

Aggregate Root

C#
public abstract class AggregateRoot
{
    private readonly List<IDomainEvent> _events = new();    
    public IReadOnlyCollection<IDomainEvent> Events => _events;    
    protected void Raise(IDomainEvent e) => _events.Add(e);    
    public void ClearEvents() => _events.Clear();
}

Domain Event

C#
public interface IDomainEvent
{
    DateTime OccurredOnUtc { get; }
}
C#
public record OrderCreatedEvent(Guid OrderId, DateTime OccurredOnUtc<br>) : IDomainEvent;

Entity

C#
public class Order : AggregateRoot
{
    private readonly List<OrderItem> _items = new();    
    public Guid Id { get; private set; }
    public DateTime CreatedAtUtc { get; private set; }    
    public IReadOnlyCollection<OrderItem> Items => _items;    
    private Order() { }    
    public static Order Create()
    {
        var order = new Order
        {
            Id = Guid.NewGuid(),
            CreatedAtUtc = DateTime.UtcNow
        };        order.Raise(new OrderCreatedEvent(order.Id, DateTime.UtcNow));        
        return order;
    }   
     
    public void AddItem(string productCode, int quantity)
    {
        if (quantity <= 0)
            throw new Exception("Invalid quantity");        
        _items.Add(new OrderItem(productCode, quantity));
    }
}

Application Layer (CQRS)

C#
Using MediatR

Command

C#
public record CreateOrderCommand(
    List<CreateOrderItemDto> Items
) : IRequest<Guid>;
public record CreateOrderItemDto(string ProductCode, int Quantity);

Handler

C#
public class CreateOrderHandler 
    : IRequestHandler<CreateOrderCommand, Guid>
{
    private readonly AppDbContext _db;    
    
    public CreateOrderHandler(AppDbContext db)
    {
        _db = db;
    }    public async Task<Guid> Handle(
        CreateOrderCommand request,
        CancellationToken ct)
    {
        var order = Order.Create();        
        foreach (var item in request.Items)
            order.AddItem(item.ProductCode, item.Quantity);   
                 
        _db.Orders.Add(order);        
        await _db.SaveChangesAsync(ct);        
        return order.Id;
    }
}

Query (Read Side)

C#
public record GetOrderQuery(Guid Id) : IRequest<OrderDto>;

Query Handler

C#
public class GetOrderHandler 
    : IRequestHandler<GetOrderQuery, OrderDto?>
{
    private readonly AppDbContext _db;    public GetOrderHandler(AppDbContext db)
    {
        _db = db;
    }    public async Task<OrderDto?> Handle(
        GetOrderQuery request,
        CancellationToken ct)
    {
        return await _db.Orders
            .AsNoTracking()
            .Where(x => x.Id == request.Id)
            .Select(x => new OrderDto(
                x.Id,
                x.CreatedAtUtc,
                x.Items.Select(i =>
                    new OrderItemDto(i.ProductCode, i.Quantity)
                ).ToList()
            ))
            .FirstOrDefaultAsync(ct);
    }
}

Infrastructure Layer

MSSQL Configuration

C#
builder.Services.AddDbContext<AppDbContext>(opt =>
    opt.UseSqlServer(
        builder.Configuration.GetConnectionString("sql")
    ));

Outbox Pattern

Table

C#
public class OutboxMessage
{
    public Guid Id { get; set; }
    public string Type { get; set; } = default!;
    public string Payload { get; set; } = default!;
    public DateTime OccurredOnUtc { get; set; }
    public DateTime? ProcessedOnUtc { get; set; }
}

Save Events Automatically

C#
public override async Task<int> SaveChangesAsync(
    CancellationToken ct = default)
{
    var events = ChangeTracker
        .Entries<AggregateRoot>()
        .SelectMany(x => x.Entity.Events)
        .ToList();    foreach (var e in events)
    {
        OutboxMessages.Add(new OutboxMessage
        {
            Id = Guid.NewGuid(),
            Type = e.GetType().Name,
            Payload = JsonSerializer.Serialize(e),
            OccurredOnUtc = DateTime.UtcNow
        });
    }    var result = await base.SaveChangesAsync(ct);    
    foreach (var entity in ChangeTracker
                 .Entries<AggregateRoot>())
    {
        entity.Entity.ClearEvents();
    }    return result;
}

RabbitMQ Integration

Publisher

C#
public class RabbitPublisher
{
    private readonly IConnection _connection;    public RabbitPublisher(IConnection connection)
    {
        _connection = connection;
    }    
    public void Publish(string exchange, string routingKey, object message)
    {
        using var channel = _connection.CreateModel();        
        channel.ExchangeDeclare(exchange, ExchangeType.Topic, true);
        
        var body = Encoding.UTF8.GetBytes(
            JsonSerializer.Serialize(message)
        );        
        
        channel.BasicPublish(exchange, routingKey, null, body);
    }
}

Background Worker (Outbox Processor)

C#
public class OutboxWorker : BackgroundService
{
    private readonly IServiceScopeFactory _scopeFactory;    public OutboxWorker(IServiceScopeFactory scopeFactory)
    {
        _scopeFactory = scopeFactory;
    }    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        while (!ct.IsCancellationRequested)
        {
            using var scope = _scopeFactory.CreateScope();
            var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
            var publisher = scope.ServiceProvider.GetRequiredService<RabbitPublisher>();
            var messages = await db.OutboxMessages
                .Where(x => x.ProcessedOnUtc == null)
                .Take(20)
                .ToListAsync(ct);
                
                foreach (var msg in messages)
            {
                publisher.Publish("orders", "order.created", msg.Payload);
                msg.ProcessedOnUtc = DateTime.UtcNow;
            }            
            await db.SaveChangesAsync(ct);
            await Task.Delay(2000, ct);
        }
    }
}

Angular Frontend

Service

JavaScript
@Injectable({ providedIn: 'root' })
export class OrderService {
  private api = '/api/orders';  constructor(private http: HttpClient) {}  create(order: any) {
    return this.http.post<string>(this.api, order);
  }  get(id: string) {
    return this.http.get<any>(`${this.api}/${id}`);
  }
}

Component

JavaScript
@Component({
  selector: 'app-order',
  template: `
    <button (click)="create()">Create Order</button>
    <pre>{{order | json}}</pre>
  `
})
export class OrderComponent {
  order: any;  constructor(private api: OrderService) {}  create() {
    const payload = {
      items: [{ productCode: 'PLC-001', quantity: 5 }]
    };    this.api.create(payload).subscribe(id => {
      this.api.get(id).subscribe(o => this.order = o);
    });
  }
}

Scaling This Architecture

Add Read Database

Write DB → events → RabbitMQ → Read DB

Add Microservices

Order Service → RabbitMQ → Inventory Service
→ Billing Service
→ Reporting Service

Add Cache

Query → Cache → DB fallback

Summary

This architecture gives:

  • clear separation (CQRS)
  • reliable messaging (RabbitMQ)
  • transactional consistency (Outbox)
  • scalability (async processing)
  • maintainability (DDD + SOLID)

This is a baseline for serious enterprise systems.


Source

https://github.com/rafalkukuczka/EnterpriseArchitecture

References

https://learn.microsoft.com/en-us/aspnet/core/get-started?view=aspnetcore-10.0

https://angular.dev/tutorials

https://www.microsoft.com/pl-pl/sql-server

https://www.rabbitmq.com

https://en.wikipedia.org/wiki/Command_Query_Responsibility_Segregation

https://en.wikipedia.org/wiki/Domain-driven_design

More Info

We implement this in real production systems.
If you need help → contact us

Contact