Domain-Driven Design in C# is a set of strategic and tactical patterns for building software whose structure mirrors a complex business domain. It is written for experienced .NET developers and architects who own long-lived systems with rules that change often. This guide covers ubiquitous language, bounded contexts and context maps, EventStorming, aggregates and invariants, entities and value objects built with C# records, domain events, repositories, domain services, anti-corruption layers, and how to map the model with EF Core 10 without compromising it.

What Is Domain-Driven Design?#

Eric Evans introduced Domain-Driven Design (DDD) in his book Domain-Driven Design: Tackling Complexity in the Heart of Software, and Vaughn Vernon's Implementing Domain-Driven Design later made the building blocks concrete for practitioners. The core claim is that in most business applications the hard part is the domain, not the technology. So the team invests in a model developed together with domain experts, and the code expresses that model directly.

DDD has two halves. Strategic design decides how to split a large domain into bounded contexts and how those contexts relate. Tactical design provides building blocks inside one context: entities, value objects, aggregates, domain events, repositories and domain services. Strategic design matters more. Teams that adopt only the tactical patterns, putting aggregates and repositories everywhere, get the complexity without the benefits.

Not every part of a system deserves the same investment. The core subdomain is where the business competes, and it justifies a rich model. Supporting subdomains are necessary but not differentiating, so simpler CRUD designs are fine there. Generic subdomains, such as identity or payments, are usually better bought or delegated to a product than modeled from scratch.

How Domain-Driven Design Works#

DDD separates the problem space (subdomains the business has) from the solution space (bounded contexts you build). Within each bounded context, the team agrees on a ubiquitous language, builds a model in that language, and implements the model with a small set of building blocks.

Building blockPurposeTypical C# shape
EntitySomething with identity and a lifecycleClass with an ID, private setters and behavior methods
Value objectA descriptive value without identitysealed record or readonly record struct, immutable
AggregateA consistency boundary around entities and valuesRoot entity that owns its children and enforces invariants
Domain eventA fact that happened in the domainImmutable record named in the past tense
Domain serviceA rule that belongs to no single entityStateless class in the domain layer
RepositoryCollection-like access to whole aggregatesInterface in the domain, EF Core implementation outside
Anti-corruption layerProtection from foreign modelsAdapter that translates at the boundary

These blocks fit naturally inside the Domain project of a Clean Architecture solution, which keeps the model free of infrastructure concerns.

Getting Started: EventStorming Your First Model#

The fastest way to discover a model is Alberto Brandolini's EventStorming: a workshop where developers and domain experts map a business process on a long wall with sticky notes. It comes in three formats: Big Picture (the whole business flow), Process Modelling (one process in detail) and Software Design (the level where aggregates emerge). The conventional colors are orange for domain events, blue for commands, lilac for policies, large yellow for aggregates or constraints, green for read models, wide pink for external systems and neon pink for hotspots.

A session starts with chaotic exploration, where everyone writes events in the past tense. The group then enforces a timeline, adds commands, actors and policies, and marks hotspots where people disagree. Clusters of commands and events that protect the same rules become aggregate candidates. Places where the vocabulary changes are strong hints for bounded context boundaries.

Text
Big Picture timeline (orange stickies, left to right)
  Cart checked out -> Order placed -> Payment authorized -> Stock reserved -> Order shipped

Software Design zoom-in on "Order placed"
  [blue]   Place order                       command, issued by a customer
  [yellow] Order                             aggregate: not empty, one currency
  [orange] Order placed                      domain event
  [lilac]  Whenever an order is placed, reserve stock    policy
  [green]  Order confirmation                read model
  [pink]   Payment provider                  external system
  [neon]   What if payment fails after stock is reserved?    hotspot

The output maps almost one to one onto code: commands become application requests, yellow stickies become aggregates, orange stickies become domain event records, lilac policies become event handlers and green stickies become projections.

Ubiquitous Language in C# Code#

The ubiquitous language is the shared vocabulary that domain experts and developers use in conversation, documentation, tests and code. When the business renames a concept, renaming the class is a model refinement, not a cosmetic change. Warning signs of drift include developers translating in meetings, CRUD verbs such as Update or Process in domain code, and a glossary nobody agrees with.

C#
// Before: CRUD vocabulary. The cancellation rule lives in whichever caller remembered it.
order.StatusId = 4;
order.UpdatedOn = DateTime.Now;
if (order.PaymentCaptured) refunds.Refund(order.Id);

// After: the method name is a business term, and the rule lives in one place.
order.Cancel(CancellationReason.CustomerRequest, clock.GetUtcNow());

Bounded Contexts and Context Maps#

A bounded context is the boundary within which one model and its language stay consistent. The same word can legitimately mean different things in different contexts, and forcing a single enterprise-wide Product class is how big balls of mud are born. In .NET, a bounded context is usually a module with its own projects, schema and DbContext inside a modular monolith, or a separately deployed service.

C#
// Catalog context: descriptive data owned by merchandising.
public sealed class Product
{
    public Sku Sku { get; private set; } = null!;
    public string Name { get; private set; } = "";
    public string MarketingDescription { get; private set; } = "";
}

// Sales context: a snapshot of what the customer agreed to pay.
public sealed record OrderedProduct(Sku Sku, string Name, Money UnitPrice);

// Shipping context: only what a carrier needs.
public sealed record Parcel(Sku Sku, decimal WeightKg, decimal VolumeLiters);

A context map documents how contexts and their teams relate. The patterns below come from Evans's work and are catalogued by the DDD Crew community.

PatternRelationshipUse it when
PartnershipMutually dependent teams plan togetherTwo contexts must ship features jointly
Shared KernelSmall, jointly governed shared modelSharing a tiny model is cheaper than translating it
Customer/SupplierUpstream plans around downstream needsThe downstream team can influence priorities
ConformistDownstream adopts the upstream modelTranslation is not worth it and upstream will not change
Anticorruption LayerDownstream translates at its boundaryThe upstream model is legacy, foreign or unstable
Open-host ServiceUpstream offers a protocol for many consumersSeveral contexts integrate with the same provider
Published LanguageA documented exchange formatContracts must be versioned independently of models
Separate WaysNo integrationThe cost of integrating outweighs the benefit
Big Ball of MudA demarcated messYou must contain a legacy system, not model it

Between contexts, integrate through a published language, such as versioned integration events or an OpenAPI contract, rather than by sharing entity classes or database tables.

Entities vs Value Objects: Modeling with C# Records#

An entity has an identity that stays the same while its attributes change: an order is the same order after its address changes. A value object is defined entirely by its attributes, so two Money values of 10 EUR are interchangeable. Value objects are immutable, validate themselves on creation and carry behavior, such as currency-safe addition, that would otherwise be scattered across services.

C# records are a natural fit because they provide value-based equality. Three caveats matter. First, a with expression copies a record and then runs init accessors, so validation placed only in a constructor is bypassed; use get-only properties, or the C# 14 field keyword to validate inside the accessor. Second, records compare collection members by reference, so value objects that hold lists need custom equality. Third, default of a record struct skips its constructor, which is why multi-field value objects are often reference records.

C#
namespace Shop.Sales.Domain;

public readonly record struct OrderId(Guid Value)
{
    public static OrderId New() => new(Guid.CreateVersion7());
}

public readonly record struct CustomerId(Guid Value);

public sealed record Money
{
    public Money(decimal amount, string currency)
    {
        ArgumentOutOfRangeException.ThrowIfNegative(amount);
        if (currency is not { Length: 3 })
            throw new ArgumentException("Use an ISO 4217 code.", nameof(currency));

        Amount = decimal.Round(amount, 2, MidpointRounding.ToEven);
        Currency = currency.ToUpperInvariant();
    }

    public decimal Amount { get; }      // get-only: 'with' cannot bypass validation
    public string Currency { get; }

    public static Money Zero(string currency) => new(0m, currency);
    public Money Add(Money other) => new(Amount + Same(other).Amount, Currency);
    public Money Subtract(Money other) => new(Amount - Same(other).Amount, Currency);
    public Money Multiply(decimal factor) => new(Amount * factor, Currency);

    private Money Same(Money other) => other.Currency == Currency
        ? other
        : throw new InvalidOperationException($"Cannot mix {Currency} and {other.Currency}.");
}

public sealed record Sku
{
    public Sku(string value) => Value = value;

    // C# 14 'field' keyword: runs for constructors and 'with' expressions alike.
    public string Value
    {
        get;
        init => field = value?.Trim() is { Length: > 0 and <= 32 } sku
            ? sku.ToUpperInvariant()
            : throw new ArgumentException("A SKU has 1 to 32 characters.", nameof(value));
    }
}

public sealed record Address(string Street, string City, string PostalCode, string CountryCode);

Strongly typed IDs such as OrderId and CustomerId cost a value converter each, but make it impossible to pass a customer ID where an order ID is expected.

AspectEntityValue object
EqualityBy identityBy all attributes
MutabilityChanges state through behavior methodsImmutable; replaced, never modified
LifecycleCreated, changed, archivedCreated and discarded freely
C# shapeClass with private settersrecord, get-only or validated init properties
EF Core mappingEntity type with a keyComplex type or value converter

Aggregates and Invariants#

An aggregate is a cluster of entities and value objects treated as one unit for changes. Outside code may hold a reference only to the aggregate root, every change goes through the root, and the root guarantees that the aggregate's invariants hold after each operation. The aggregate is also the transaction boundary: one command should modify one aggregate.

Vaughn Vernon's aggregate design rules remain the best guidance: protect true invariants inside consistency boundaries, design small aggregates, reference other aggregates by identity, and use eventual consistency between aggregates. The sizing question is always the same: must these two things be consistent at the same instant? If yes, they belong together. If a few seconds of delay is acceptable, separate them and connect them with domain events. Oversized aggregates also cause optimistic concurrency conflicts, because unrelated edits contend for the same row version.

C#
namespace Shop.Sales.Domain.Orders;

public interface IDomainEvent
{
    DateTimeOffset OccurredAt { get; }
}

public interface IHasDomainEvents
{
    IReadOnlyCollection<IDomainEvent> DomainEvents { get; }
    void ClearDomainEvents();
}

public abstract class AggregateRoot<TId> : IHasDomainEvents where TId : struct
{
    private readonly List<IDomainEvent> _domainEvents = [];

    public TId Id { get; protected init; }
    public IReadOnlyCollection<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();

    protected void Raise(IDomainEvent domainEvent) => _domainEvents.Add(domainEvent);
    public void ClearDomainEvents() => _domainEvents.Clear();
}

public sealed record OrderPlaced(
    OrderId OrderId, CustomerId CustomerId, Money Total, DateTimeOffset OccurredAt)
    : IDomainEvent;

public sealed class Order : AggregateRoot<OrderId>
{
    public const int MaxDistinctItems = 50;
    private readonly List<OrderLine> _lines = [];

    private Order() { } // For EF Core.

    private Order(CustomerId customerId, Address shippingAddress, string currency)
    {
        Id = OrderId.New();
        CustomerId = customerId;
        ShippingAddress = shippingAddress;
        Currency = currency;
        Discount = Money.Zero(currency);
    }

    public CustomerId CustomerId { get; private set; }
    public Address ShippingAddress { get; private set; } = null!;
    public string Currency { get; private set; } = null!;
    public Money Discount { get; private set; } = null!;
    public OrderStatus Status { get; private set; } = OrderStatus.Draft;
    public IReadOnlyCollection<OrderLine> Lines => _lines.AsReadOnly();

    public Money Subtotal =>
        _lines.Aggregate(Money.Zero(Currency), (sum, line) => sum.Add(line.Subtotal));
    public Money Total => Subtotal.Subtract(Discount);

    public static Order Start(CustomerId customerId, Address shippingAddress, string currency) =>
        new(customerId, shippingAddress, currency);

    public void AddItem(Sku sku, int quantity, Money unitPrice)
    {
        EnsureDraft();
        if (unitPrice.Currency != Currency)
            throw new DomainException($"This order is priced in {Currency}.");

        var line = _lines.Find(l => l.Sku == sku);
        if (line is not null)
        {
            line.IncreaseQuantity(quantity);
            return;
        }

        if (_lines.Count == MaxDistinctItems)
            throw new DomainException($"An order holds at most {MaxDistinctItems} items.");
        _lines.Add(new OrderLine(sku, quantity, unitPrice));
    }

    public void ApplyDiscount(Money discount)
    {
        EnsureDraft();
        if (discount.Amount > Subtotal.Amount)
            throw new DomainException("A discount cannot exceed the subtotal.");
        Discount = discount;
    }

    public void Place(DateTimeOffset now)
    {
        EnsureDraft();
        if (_lines.Count == 0)
            throw new DomainException("Cannot place an empty order.");

        Status = OrderStatus.Placed;
        Raise(new OrderPlaced(Id, CustomerId, Total, now));
    }

    private void EnsureDraft()
    {
        if (Status != OrderStatus.Draft)
            throw new DomainException($"Order {Id.Value} is {Status}, not Draft.");
    }
}

public sealed class OrderLine
{
    private OrderLine() { } // For EF Core.

    internal OrderLine(Sku sku, int quantity, Money unitPrice)
    {
        Sku = sku;
        UnitPrice = unitPrice;
        IncreaseQuantity(quantity);
    }

    public Sku Sku { get; private set; } = null!;
    public int Quantity { get; private set; }
    public Money UnitPrice { get; private set; } = null!;
    public Money Subtotal => UnitPrice.Multiply(Quantity);

    internal void IncreaseQuantity(int quantity)
    {
        if (quantity <= 0 || Quantity + quantity > 999)
            throw new DomainException("Quantity per item must stay between 1 and 999.");
        Quantity += quantity;
    }
}

public enum OrderStatus { Draft, Placed, Shipped, Cancelled }

public sealed class DomainException(string message) : Exception(message);

OrderLine has an internal constructor, so only the root can create lines, and the customer is referenced by CustomerId rather than by a navigation property. Add an optimistic concurrency token, such as a rowversion column on SQL Server, so that two concurrent commands on the same order cannot both succeed.

Domain Events#

A domain event records something the business cares about, named in the past tense: OrderPlaced, not PlaceOrder. Aggregates raise events by adding them to a collection, and infrastructure dispatches them when the unit of work is saved. This deferred approach, recommended in Microsoft's microservices guidance, keeps entities free of dispatch plumbing and easy to test.

Distinguish two kinds of events. Domain events are in-process messages within one bounded context. Integration events tell other contexts about committed changes, travel asynchronously through a broker and must be published reliably, typically through a transactional outbox.

When to dispatch domain events is a real design decision. Dispatching before the commit lets handlers change other aggregates in the same transaction; the eShop reference application's Ordering service does this. Dispatching after the commit, or through the outbox, honors the one-aggregate-per-transaction rule at the cost of eventual consistency and compensating actions.

C#
public sealed class SalesDbContext(
    DbContextOptions<SalesDbContext> options, IDomainEventDispatcher dispatcher)
    : DbContext(options)
{
    public DbSet<Order> Orders => Set<Order>();

    public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
    {
        var sources = ChangeTracker.Entries<IHasDomainEvents>()
            .Select(entry => entry.Entity)
            .Where(entity => entity.DomainEvents.Count > 0)
            .ToList();

        var domainEvents = sources.SelectMany(source => source.DomainEvents).ToList();
        sources.ForEach(source => source.ClearDomainEvents());

        // In-process handlers run before the commit, so their changes join this transaction.
        foreach (var domainEvent in domainEvents)
            await dispatcher.DispatchAsync(domainEvent, cancellationToken);

        return await base.SaveChangesAsync(cancellationToken);
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder) =>
        modelBuilder.ApplyConfigurationsFromAssembly(typeof(SalesDbContext).Assembly);
}

Handlers invoked this way must not call SaveChangesAsync themselves. If event history itself should be the source of truth, see event sourcing.

Repositories and Domain Services#

A repository gives the illusion of an in-memory collection of aggregates. Define one per aggregate root, return whole aggregates, and expose intention-revealing methods instead of IQueryable. Queries for screens and reports do not need repositories at all; they can project directly from the database, which is the core idea behind CQRS.

A domain service holds a domain rule that does not belong to a single entity, typically because it needs two aggregates. It is stateless, named in the ubiquitous language and ideally free of I/O. Do not confuse it with an application service, which orchestrates a use case, or with an infrastructure service, which talks to the outside world.

C#
// Domain: one repository per aggregate root.
public interface IOrderRepository
{
    Task<Order?> FindAsync(OrderId id, CancellationToken ct);
    void Add(Order order);
}

// Domain service: the discount depends on two aggregates, so it belongs to neither.
public sealed class LoyaltyDiscountPolicy
{
    public Money DiscountFor(Order order, Customer customer)
    {
        if (customer.IsBlocked)
            return Money.Zero(order.Currency);

        var rate = customer.Tier switch
        {
            LoyaltyTier.Gold => 0.10m,
            LoyaltyTier.Silver => 0.05m,
            _ => 0m
        };
        return order.Subtotal.Multiply(rate);
    }
}

// Infrastructure: EF Core implementation.
internal sealed class OrderRepository(SalesDbContext db) : IOrderRepository
{
    public Task<Order?> FindAsync(OrderId id, CancellationToken ct) =>
        db.Orders.Include(o => o.Lines).SingleOrDefaultAsync(o => o.Id == id, ct);

    public void Add(Order order) => db.Orders.Add(order);
}

The application service then reads both aggregates, asks the policy for a discount and calls order.ApplyDiscount(...), so the order still enforces its own invariant that a discount never exceeds the subtotal.

Anti-Corruption Layers#

An anti-corruption layer (ACL) lets a downstream context use an upstream system, such as a legacy ERP, a partner API or another team's service, without adopting its model. It translates requests and responses at the boundary and rejects what the local model cannot represent. In .NET, an ACL is usually a typed HttpClient or database gateway in Infrastructure that implements a port defined in the local model. ACLs are also the backbone of strangler fig migrations away from legacy systems.

C#
namespace Shop.Shipping.Infrastructure.Carriers;

// The carrier's DTOs and codes never leave this class.
internal sealed class ParcelCoGateway(ParcelCoClient client) : IShippingRates
{
    public async Task<ShippingQuote> QuoteAsync(
        Parcel parcel, Address destination, CancellationToken ct)
    {
        var response = await client.GetRatesAsync(new ParcelCoRateRequest
        {
            WeightGrams = (int)Math.Ceiling(parcel.WeightKg * 1000),
            CountryIso2 = destination.CountryCode,
            Zip = destination.PostalCode,
            Svc = "STD"
        }, ct);

        var rate = response.Rates.SingleOrDefault(r => r.Svc == "STD" && r.Avail == "Y")
            ?? throw new ShippingUnavailableException(destination.CountryCode);

        return new ShippingQuote(
            Price: new Money(rate.PriceCents / 100m, rate.Ccy),
            EstimatedTransit: TimeSpan.FromDays(rate.TransitDays));
    }
}

Mapping DDD Models with EF Core#

EF Core supports rich domain models well, provided you let it use fields and constructors instead of public setters. It can materialize entities through private parameterless constructors, write private setters, discover backing fields such as _lines by convention, and bind constructor parameters for immutable types. Strongly typed IDs and single-value objects such as Sku map through value converters. Multi-property value objects such as Money and Address map as complex types, introduced in EF Core 8.

EF Core 10, the current LTS release, made complex types the natural choice for value objects. It added optional complex types, mapping to JSON columns, collections of complex types (JSON only) and bulk updates of JSON-mapped values with ExecuteUpdate. The EF Core documentation now suggests complex types over owned entity types for value objects, because owned types carry hidden keys and cannot be shared between owners. Owned types still work; the eShop Ordering service maps its Address with OwnsOne.

C#
internal sealed class OrderConfiguration : IEntityTypeConfiguration<Order>
{
    public void Configure(EntityTypeBuilder<Order> builder)
    {
        builder.ToTable("orders");
        builder.HasKey(o => o.Id);
        builder.Property(o => o.Id)
            .HasConversion(id => id.Value, value => new OrderId(value))
            .ValueGeneratedNever();
        builder.Property(o => o.CustomerId)
            .HasConversion(id => id.Value, value => new CustomerId(value));
        builder.Property(o => o.Status).HasConversion<string>().HasMaxLength(16);
        builder.Property(o => o.Currency).HasMaxLength(3);

        // Value objects as complex types: columns such as ShippingAddress_City.
        builder.ComplexProperty(o => o.ShippingAddress);
        builder.ComplexProperty(o => o.Discount,
            money => money.Property(m => m.Amount).HasPrecision(18, 2));

        // Children are reachable only through the root and its private field.
        builder.HasMany(o => o.Lines).WithOne().HasForeignKey("OrderId");
        builder.Navigation(o => o.Lines).UsePropertyAccessMode(PropertyAccessMode.Field);

        builder.Ignore(o => o.DomainEvents);
    }
}

internal sealed class OrderLineConfiguration : IEntityTypeConfiguration<OrderLine>
{
    public void Configure(EntityTypeBuilder<OrderLine> builder)
    {
        builder.ToTable("order_lines");
        builder.Property<int>("Id");
        builder.HasKey("Id");
        builder.Property(l => l.Sku)
            .HasConversion(sku => sku.Value, value => new Sku(value))
            .HasMaxLength(32);
        builder.ComplexProperty(l => l.UnitPrice,
            money => money.Property(m => m.Amount).HasPrecision(18, 2));
    }
}
CriterionComplex typeOwned entity type
IdentityNone, pure value semanticsHidden key managed by EF Core
Sharing an instance between ownersAllowedNot allowed
StorageOwner's table or a JSON column (EF Core 10)Owner's table, separate table or JSON
CollectionsJSON column only (EF Core 10)Separate table via OwnsMany
ExecuteUpdate supportYesLimited
Best forValue objects in new modelsExisting models already using owned types

Keep all of this in IEntityTypeConfiguration<T> classes in Infrastructure, so the domain carries no persistence attributes. The EF Core guide covers value converters, JSON columns and migrations in depth.

Best Practices#

  • Start strategic. Identify the core subdomain and bounded contexts before writing aggregate classes.
  • Make the model speak the language. Method names should be verbs the business uses; tests should read like the rules experts describe.
  • Keep aggregates small. Include only what must be consistent immediately; reference everything else by ID.
  • Make invalid states unrepresentable. Validate in value object constructors and aggregate methods, not in controllers.
  • Separate domain events from integration events. Dispatch domain events in-process; publish integration events through an outbox.
  • Let reads bypass the model. Project queries straight into DTOs instead of loading aggregates.
  • Revisit the model. Schedule EventStorming refreshes when the business changes, not just at project start.

Common Pitfalls#

Tactical patterns everywhere. Aggregates, repositories and domain events in a CRUD subdomain add ceremony without protecting any rule.

One model to rule them all. A shared Customer class used by sales, billing and support accumulates every team's fields and nobody's rules. Split contexts and translate between them.

Anemic entities. Public setters plus service classes full of if statements are a transaction script in disguise. Move behavior next to the data it protects.

Aggregates designed around the UI. A screen that shows an order with its customer and shipments is a read model, not a reason to make one giant aggregate.

Sharing database tables between contexts. Two contexts writing the same table have a hidden shared kernel that nobody governs.

Leaking persistence into the domain. Navigation properties added only for queries, or EF attributes on entities, bend the model toward the database.

When to Use Domain-Driven Design#

SituationRecommendation
Core subdomain with complex, changing rulesFull DDD: strategic and tactical patterns
Supporting subdomain, mostly data entryStrategic boundaries, simple CRUD inside
Generic subdomain such as identity or paymentsBuy or integrate a product behind an ACL
Several teams working on one large systemBounded contexts and context maps first
Small application with a single team and simple rulesSkip tactical DDD; keep the ubiquitous language

Frequently Asked Questions#

Should value objects in C# be records or classes?#

Records are usually the best fit because they provide value-based equality and concise immutable syntax. Use get-only properties or C# 14 field-backed init accessors so with expressions cannot bypass validation, and override equality when a value object contains a collection. A readonly record struct suits small single-field values such as strongly typed IDs.

How big should an aggregate be?#

As small as possible while still protecting a true invariant. If two pieces of data must be consistent at the same instant, keep them in one aggregate; otherwise separate them and coordinate with domain events. Large aggregates hurt performance and cause concurrency conflicts.

Is a bounded context the same as a microservice?#

Not necessarily. A bounded context is a model boundary, while a microservice is a deployment boundary. A context often maps to one service, but it can also live as a module in a monolith; see the microservices guide for when to split deployments.

Should I use EF Core owned types or complex types for value objects?#

For new models on EF Core 10, prefer complex types: they have true value semantics, no hidden keys, and support JSON mapping and bulk updates. Owned types remain supported and are reasonable in existing codebases, but they cannot share an instance between owners.

Do I need event sourcing to do DDD?#

No. Most DDD systems persist aggregates as current state with EF Core or another ORM. Event sourcing is an optional persistence strategy that fits when the history of changes is itself valuable, such as audit-heavy or temporal domains.

Summary#

  • DDD is strategic first: find the core subdomain, draw bounded contexts and document how they relate.
  • Use EventStorming to discover events, commands, policies and aggregate boundaries with domain experts.
  • Model entities with behavior, value objects as validated records, and aggregates as small consistency boundaries.
  • Raise domain events inside aggregates; publish integration events reliably through an outbox.
  • Map the model with EF Core 10 using private setters, backing fields, value converters and complex types.

Further Reading#