DEV Community

Pathum Kumara
Pathum Kumara

Posted on

Building Scalable Backends with DDD & Domain Events .NET C#

Over the last few days, I sepent time refining architectural patterns in a modular .NET backend centered around payroll processing, approvals, and workflow-driven operations.

One area I focused on heavily was aggregate design and state protection. Instead of exposing mutable collections directly from entities, aggregates internally manage their own state exposing read-only access externally.

private readonly List _monthlyAllowance = new();

public IReadOnlyCollection
MonthlyAllowances => _monthlyAllowance ;

This prevents external code from bypassing aggregate rules;

employee.MonthlyAllowances.Add(...)

while still allowing controlled state transitions through aggregate methods:

public void AddMonthlyAllowance(MonthlyAllowance allowance)
{
if(_monthlyAllowances.Any(x => x.PayrollMonth == allowance.PayrollMonth && x.SalaryItemId == allowance.SalaryItemId))
{
throw new DuplicateMonthlyAllowanceException();
}

_monthlyAllowances.Add(allowance);

}

Another major refinement was moving workflow reactions out of aggregates and into domain event handlers. Rather than aggregates directly triggering notifications or workflows, aggregates simply raise business events.

public void AddMonthlyAllowance(MonthlyAllowance allowance)
{
_monthlyAllowances.Add(allowance);

AddDomainEvent(new MonthlyAllowanceSubmittedForApprovalDomainEvent(Id, allowance.Id, EmployeeName));

}

The aggregate only represents business behavior. Reactions happen externally through handlers:

public sealed class
MonthlyAllowanceSubmittedForApprovalDomainEventHandler
: INotificationHandler<
MonthlyAllowanceSubmittedForApprovalDomainEvent>
{
public async Task Handle(
MonthlyAllowanceSubmittedForApprovalDomainEvent notification,
CancellationToken cancellationToken)
{
await _notificationRepository.AddAsync(
new HrNotification(
"Allowance Approval Required",
$"Approval required for {notification.EmployeeName}",
"HR_MANAGER"));
}
}

This separation dramatically reduces coupling and keeps aggregates focused on business invariants instead of orchestration concerns.

I also revisited feature-based application organization. As systems scale, grouping code by business capability rather than technical type becomes significantly easier to maintain.

Instead of:

Application
├── Commands
├── Queries
├── Handlers

feature-oriented organization tends to scale better:

Application
└── EmployeePayrollProfile
├── Commands
├── Queries
├── EventHandlers
├── Validators
└── DTOs

Another area that improved domain clarity considerably was replacing primitive-heavy models with explicit value objects.

Instead of:

public int cYear;
public int cMonth;
public double Amount;

the model becomes much more expressive:

public PayrollMonth PayrollMonth { get; }
public Money Amount { get; }

with validation centralized inside the value object itself:

public sealed class PayrollMonth : ValueObject
{
public int Year { get; }
public int Month { get; }

public PayrollMonth(int year, int month)
{
    if (month < 1 || month > 12)
        throw new DomainException(
            "Invalid payroll month.");

    Year = year;
    Month = month;
}
Enter fullscreen mode Exit fullscreen mode

}

Approval workflows were another interesting area. Instead of tightly coupling approvals to controllers or services, workflows are modeled through state transitions and domain events:

allowance.Approve();

AddDomainEvent(
new MonthlyAllowanceApprovedDomainEvent(...));

This allows notifications, audit trails, projections, escalations, and future integrations to evolve independently without modifying aggregate behavior.

I also spent time evaluating architectural tradeoffs between:

in-process messaging and distributed messaging
modular monoliths and microservices
domain events and integration events
feature-based organization and layer-only organization

One thing that consistently becomes clear in larger backend systems is that many scalability and maintainability problems originate from coupling and boundary design long before infrastructure becomes the bottleneck.

For modular monolith architectures in particular, using MediatR with domain events provides a clean middle ground: maintaining loose coupling and workflow flexibility without introducing distributed-system complexity too early.

Current stack and concepts:
.NET 8 • EF Core • MediatR • DDD • CQRS-style patterns • Modular Monolith Architecture • Event-Driven Workflows

dotnet #csharp #softwarearchitecture #ddd #backend #cleanarchitecture #modularmonolith #mediatr #cqrs #enterprisesoftware

Top comments (0)