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
| Operation | Layer |
|---|---|
| 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
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
public interface IDomainEvent
{
DateTime OccurredOnUtc { get; }
}public record OrderCreatedEvent(Guid OrderId, DateTime OccurredOnUtc<br>) : IDomainEvent;Entity
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)
Using MediatRCommand
public record CreateOrderCommand(
List<CreateOrderItemDto> Items
) : IRequest<Guid>;
public record CreateOrderItemDto(string ProductCode, int Quantity);Handler
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)
public record GetOrderQuery(Guid Id) : IRequest<OrderDto>;Query Handler
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
builder.Services.AddDbContext<AppDbContext>(opt =>
opt.UseSqlServer(
builder.Configuration.GetConnectionString("sql")
));Outbox Pattern
Table
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
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
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)
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
@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
@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://www.microsoft.com/pl-pl/sql-server
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