← Back to list

A Pragmatic State-Oriented Aggregate Architecture for EF Core

One of the most common architectural problems in applications built with Entity Framework Core is treating database entities as the…

Özcan Boyraz · 2026-06-07 23:30 · 0 claps · 6.7 min read
#c-sharp-programming #ef-core #software-architecture #domain-driven-design #cqrs
Open on Medium ↗
Wiki topics: RAG · RAG & Retrieval 💻 · Programming 🏛️ · Architecture

A Pragmatic State-Oriented Aggregate Architecture for EF Core

One of the most common architectural problems in applications built with Entity Framework Core is treating database entities as the business model itself. In many systems, a single EF Core entity becomes simultaneously:

  • The persistence model
  • The domain model
  • The API contract
  • The serialization model
  • The validation object
  • The query model

As a result, business logic becomes scattered throughout services and controllers, entities become anemic, and the domain gradually becomes shaped by persistence concerns.

This article proposes a different approach:

  • EF Core entities represent persisted state only.
  • Aggregates represent behavior and business rules.
  • Repositories reconstruct aggregates from persisted state.

The central idea is simple: A database row is not the business object itself. It is merely the latest persisted state of that object.

This distinction may seem subtle, but it fundamentally changes how we model domains, enforce invariants, and integrate with EF Core.

The Core Concept

Instead of exposing EF Core entities directly as business objects, we separate two distinct responsibilities: Persistence State and Domain Behavior.

The persistence model contains only data that must be stored. It contains no business logic, no validation rules, and no public state-changing behavior:

public class DeliveryOrderState
{
    public Guid Id { get; set; }
    public DeliveryOrderStatus Status { get; set; }
    public DateTime CreatedAt { get; set; }
}

This object is managed directly by EF Core and represents persisted state only.

The behavioral aggregate wraps this state. Crucially, the constructor is marked internal so that external application layers cannot bypass business rules by directly manipulating the state object.

public class DeliveryOrder
{
    private readonly DeliveryOrderState _state;

    // Internal constructor: Only the repository or factories within 
    // the domain/infrastructure boundaries can reconstruct it.
    internal DeliveryOrder(DeliveryOrderState state)
    {
        _state = state ?? throw new ArgumentNullException(nameof(state));
    }

    public Guid Id => _state.Id;
    public bool IsShipped => _state.Status == DeliveryOrderStatus.Shipped;

    // Public API for executing business behavior
    public void Ship()
    {
        if (IsShipped)
            throw new InvalidOperationException("Order is already shipped.");

        _state.Status = DeliveryOrderStatus.Shipped;
    }

    // Exposes the underlying state back to the repository for persistence
    internal DeliveryOrderState GetState() => _state;
}

The repository acts as the bridge, loading the state and wrapping it inside the aggregate:

public class DeliveryOrderRepository
{
    private readonly AppDbContext _db;

    public DeliveryOrderRepository(AppDbContext db)
    {
        _db = db;
    }

    public async Task<DeliveryOrder> Get(Guid id)
    {
        var state = await _db.DeliveryOrders.FindAsync(id);
        if (state == null) return null;

        return new DeliveryOrder(state);
    }

    public async Task Add(DeliveryOrder order)
    {
        await _db.DeliveryOrders.AddAsync(order.GetState());
    }
}

Creation vs. Reconstruction

A common trap in aggregate design is failing to distinguish between reconstructing an existing aggregate from the database and creating a brand-new one.

To maintain clean boundaries, the application layer should never instantiate a DeliveryOrderState directly. Instead, the aggregate itself should expose an explicit, public static factory method to manage the generation of a clean initial state:

public class DeliveryOrder
{
    // ... existing fields and constructors ...

    // Public factory for creating brand-new orders
    public static DeliveryOrder CreateNew(Guid customerId)
    {
        var initialState = new DeliveryOrderState
        {
            Id = Guid.NewGuid(),
            Status = DeliveryOrderStatus.New,
            CreatedAt = DateTime.UtcNow
        };

        return new DeliveryOrder(initialState);
    }
}

Why This Separation Matters

1. EF Core Stops Defining the Domain Model

In traditional EF Core designs, the entity itself becomes the domain model, leading to naked properties being modified anywhere in the application:

order.Status = DeliveryOrderStatus.Shipped; // Leaked logic

With a behavioral aggregate, the aggregate controls state transitions and strictly enforces invariants:

order.Ship();

This creates a much stronger domain boundary and makes business intent explicit.

2. Persistence Becomes an Infrastructure Concern

By separating state from behavior, EF Core handles tracking and raw persistence, while aggregates handle business rules. Persistence becomes an implementation detail rather than the architectural centerpiece.

3. Rich Domain Models Become Practical Again

Many EF Core applications become anemic because developers hesitate to place complex behavior inside tracked entities due to mapping or translation limitations. The result is procedural code spread across services, handlers, and controllers:

if (order.Status == DeliveryOrderStatus.Paid)
{
    order.Status = DeliveryOrderStatus.Shipped;
}

With state-wrapped aggregates, business rules remain centralized. The aggregate regains complete authority over state transitions.

4. Better Encapsulation and Protection

The persistence state remains hidden from consumers. Consumers interact with pure business concepts (order.IsShipped) rather than direct database representations. The persistence model can evolve, columns can be renamed, or data types can change without affecting the public-facing contract of the domain model.

5. EF Core Tracking Works Naturally

A major advantage of this approach is that EF Core continues tracking the underlying state object directly through object references. When the aggregate modifies state internally:

_state.Status = DeliveryOrderStatus.Shipped;

EF Core’s change tracker detects the modification automatically. This preserves the performance, tooling, and efficiency of EF Core while avoiding the architectural pitfalls of exposing tracked entities as public domain objects.

6. Avoiding Mapping Explosion

Many DDD implementations introduce a massive mapping overhead: EF Entity → Persistence DTO → Domain Object → UI Contract. While conceptually pure, the maintenance cost is exhausting.

This architecture intentionally eliminates that layer. The EF Core entity is the persistence state object (DeliveryOrderState). There is no need for an intermediary persistence DTO. We preserve structural separation without writing a single line of tedious boilerplate mapping code.

Querying and LINQ: Embrace CQRS

One of the first questions raised by this architecture is: How should LINQ queries be handled if the data is trapped inside an aggregate wrapper?

The answer is straightforward: Queries operate directly on the persistence state, completely bypassing the behavioral aggregate.

If you are fetching data for a UI grid, an API response, or a report, you do not need business logic, validation rules, or state mutation capabilities. Forcing a read operation to instantiate dozens of behavioral aggregates is a waste of CPU cycles and memory.

Instead, read operations should query DeliveryOrderState directly and project straight into thin, read-only DTOs:

// Pure Read Model - Fast, lightweight, and bypasses the Aggregate layer entirely
public async Task<List<OrderSummaryDto>> GetRecentOrdersQuery()
{
    return await _db.DeliveryOrders
        .Where(x => x.Status == DeliveryOrderStatus.Shipped)
        .Select(x => new OrderSummaryDto 
        { 
            Id = x.Id, 
            CreatedAt = x.CreatedAt 
        })
        .ToListAsync();
}

This architecture naturally aligns with Command-Query Responsibility Segregation (CQRS) principles:

  • Queries: Project raw data directly from State objects into lightweight DTOs.
  • Commands: Load the behavioral Aggregate via the repository, execute business rules, and save the state graph back to the database.
// Command Handler Example
var order = await _repository.Get(command.OrderId);

order.Ship(); // Logic executed internally

await _db.SaveChangesAsync(); // EF tracking handles the rest automatically

Why Private Setters Are Not Enough

A common alternative in the .NET community is to use EF Core entities directly while restricting mutation through C# private setters:

public class DeliveryOrder
{
    public DeliveryOrderStatus Status { get; private set; }

    public void Ship()
    {
        Status = DeliveryOrderStatus.Shipped;
    }
}

While private setters improve encapsulation of mutation, they do not solve the deeper architectural issue. The object remains simultaneously responsible for database mapping, change tracking, navigation property configuration, LINQ-to-SQL query translation, serialization, and domain behavior.

The distinction is critical:

  • Private setters = Encapsulation of mutation.
  • State + Aggregate separation = Encapsulation of responsibility.

Modeling Aggregate State Graphs

This approach becomes particularly interesting when an aggregate owns a complex child graph, such as a delivery order containing line items.

The persistence models reflect the pure database relationship:

public class DeliveryOrderState
{
    public Guid Id { get; set; }
    public DeliveryOrderStatus Status { get; set; }
    public List<DeliveryLineItemState> LineItems { get; set; } = new();
}

public class DeliveryLineItemState
{
    public Guid Id { get; set; }
    public string Sku { get; set; }
    public int Quantity { get; set; }
    public decimal UnitPrice { get; set; }
}

If these line items cannot exist independently of the order, they belong strictly to the DeliveryOrder aggregate boundary.

However, exposing IReadOnlyCollection<DeliveryLineItemState> directly from the parent aggregate exposes a hidden vulnerability: even though the collection is read-only, the individual items within it are still mutable. An external consumer could iterate through your collection and modify a property directly (item.Quantity = 999), bypassing your aggregate's business validation while EF Core silently tracks and saves the change.

The Solution: The Read-Only Domain Wrapper

To prevent external tampering without resorting to complex mapping layers, we introduce a lightweight, read-only domain wrapper for the child items. This object takes the state in its constructor but exposes only get-only properties:

public class DeliveryLineItem
{
    private readonly DeliveryLineItemState _state;

    // Internal constructor: Only the parent aggregate root can instantiate this wrapper
    internal DeliveryLineItem(DeliveryLineItemState state)
    {
        _state = state;
    }

    public Guid Id => _state.Id;
    public string Sku => _state.Sku;
    public int Quantity => _state.Quantity;
    public decimal UnitPrice => _state.UnitPrice;

    // Read-only domain logic can live here comfortably
    public decimal TotalPrice => _state.Quantity * _state.UnitPrice;
}

The parent aggregate root now maintains complete control over operations across the entire graph. It exposes the safe domain wrappers to the outside world, while keeping mutations restricted to explicit domain methods:

public class DeliveryOrder
{
    private readonly DeliveryOrderState _state;

    internal DeliveryOrder(DeliveryOrderState state) { _state = state; }

    // Expose the safe domain wrapper dynamically, NOT the raw mutable state objects
    public IReadOnlyCollection<DeliveryLineItem> LineItems => 
        _state.LineItems.Select(itemState => new DeliveryLineItem(itemState)).ToList().AsReadOnly();

    public void AddItem(string sku, int quantity, decimal unitPrice)
    {
        if (_state.Status == DeliveryOrderStatus.Shipped)
            throw new InvalidOperationException("Cannot add items to a shipped order.");

        // Mutating the underlying tracked state graph safely within the invariant boundary
        _state.LineItems.Add(new DeliveryLineItemState 
        { 
            Id = Guid.NewGuid(),
            Sku = sku, 
            Quantity = quantity,
            UnitPrice = unitPrice
        });
    }
}

The repository remains simple, ensuring the entire graph is loaded atomically:

var state = await _db.DeliveryOrders
    .Include(x => x.LineItems)
    .SingleAsync(x => x.Id == id);

return new DeliveryOrder(state);

Conceptual Model Summary

Conclusion

By separating persistence state, domain behavior, and aggregate reconstruction, we achieve a pragmatic balance that extracts maximum value from both DDD principles and ORM capabilities.

Rather than treating EF entities as the business models themselves or forcing developers into complex, multi-layered mapping architectures, this approach cleanly uses EF Core entities purely as tracking mechanisms for state, allowing your core business domain to focus entirely on behavior.


메타데이터
post_id
56f8e188cbb4
slug
a-pragmatic-state-oriented-aggregate-architecture-for-ef-core-56f8e188cbb4
url
https://medium.com/@ozcan.boyraz/a-pragmatic-state-oriented-aggregate-architecture-for-ef-core-56f8e188cbb4
canonical_url
https://medium.com/@ozcan.boyraz/a-pragmatic-state-oriented-aggregate-architecture-for-ef-core-56f8e188cbb4
author_url
https://medium.com/@ozcan.boyraz
status
ok
fetched_at
2026-06-15 20:49:13