# DDDToolkit > Source generators that remove the repetitive parts of domain driven design in .NET. You declare the intent with an attribute; the generator writes the base type, the equality members, the identifier plumbing, the persistence mapping and the API conversions. # Getting started This page builds one module of a small shop, step by step: an identifier, a value object, an aggregate with its rules, a test, a database, an HTTP endpoint, and finally a second module that reacts to the first. Each step adds one thing, and says what the generator writes for it. Every snippet comes from a project that builds and runs, [`Examples/ModularMonolith.Supabase`](https://github.com/DylanSnel/DDDToolkit/tree/main/Examples/ModularMonolith.Supabase), shortened to what the step is about. The shop has five modules, `Catalog`, `Ordering`, `Inventory`, `Payments` and `Shipping`, run in one host. This page follows `Ordering` and the module that reacts to it last, `Shipping`; the others use the same pieces. The path under each snippet points at the real file, so you can go and look at the rest of it. ## Install ```bash dotnet add package Temp.DDDToolkit ``` For now 3.x is published under `Temp.` package ids; `DDDToolkit` on nuget.org is still 2.0.22, which these pages do not describe. The namespaces are `DDDToolkit` either way. See [Packages](https://github.com/DylanSnel/DDDToolkit/blob/main/README.md#packages). `DDDToolkit` brings the base types and the core generators. That is all the first steps need. Each integration comes with a package of its own, added in the step that uses it: Entity Framework when the aggregate is stored, the testing kit when it is tested. The libraries target .NET 10. The generators target `netstandard2.0` and reference nothing at run time, so they load in any recent SDK without version conflicts. ## Declare an identifier An order needs an id. A `Guid` would do, until somebody passes a customer's `Guid` where an order's was meant and the compiler cannot tell. An identifier type makes that a compile error: ```csharp [EntityId("ORD")] public readonly partial record struct OrderId; ``` *[`Ordering.Contracts/OrderingContracts.cs`](https://github.com/DylanSnel/DDDToolkit/blob/main/Examples/Modules/Ordering/DDDToolkit.Examples.Ordering.Contracts/OrderingContracts.cs)* Two rules: the type must be `partial` so the generator can add to it, and it must be a record. Use `readonly partial record struct` unless you have a reason not to; it costs no allocation. The generator writes the rest: ```csharp title="OrderId.g.cs, shortened" [JsonConverter(typeof(OrderId.SystemTextJsonConverter))] public readonly partial record struct OrderId : IEntityId, IComparable, IParsable { public const string IdPrefix = "ORD"; public Guid Value { get; } public OrderId(Guid value) { Value = value; } public static OrderId CreateUnique() => new(Guid.NewGuid()); public static OrderId CreateSequential() => new(Guid.CreateVersion7()); public override string ToString() => /* "ORD_" followed by the Guid */; public static OrderId Parse(string input) { /* the prefix is optional */ } public static bool TryParse(string? input, out OrderId result) { /* ... */ } public sealed class SystemTextJsonConverter : JsonConverter { /* ... */ } } ``` Most identifiers do not need a declaration at all. Name the raw value on the entity and the toolkit generates the identifier with it: ```csharp [Entity("LINE")] public partial class OrderLine // also generates OrderLineId ``` *[`Ordering/Domain/Aggregates/Orders/Entities/OrderLine.cs`](https://github.com/DylanSnel/DDDToolkit/blob/main/Examples/Modules/Ordering/DDDToolkit.Examples.Ordering/Domain/Aggregates/Orders/Entities/OrderLine.cs)* Use the short form for the identifier nobody outside the aggregate mentions, and the explicit form for the identifier everybody does. `OrderId` is written out because it is stored, parsed from URLs and sent to other modules, so it deserves a file to navigate to. See [Identifiers](https://dylansnel.github.io/DDDToolkit/docs/identifiers.md). ## Declare a value object An order is shipped to an address. An address has no identity of its own: two addresses with the same street, city and postal code are the same address. That makes it a value object, and it carries its own rules: ```csharp [ValueObject] public partial record Address { public Address(string street, string city, string postalCode) => (Street, City, PostalCode) = (street, city, postalCode); [JsonInclude] public string Street { get; protected init; } [JsonInclude] public string City { get; protected init; } [JsonInclude] public string PostalCode { get; protected init; } protected override void Validate(ValidationErrorBuilder errors) { if (string.IsNullOrWhiteSpace(Street)) { errors.Add("A street is required.", nameof(Street), "Required", Street); } // ... } } ``` *[`Ordering/Domain/ValueObjects/Address.cs`](https://github.com/DylanSnel/DDDToolkit/blob/main/Examples/Modules/Ordering/DDDToolkit.Examples.Ordering/Domain/ValueObjects/Address.cs)* Setters are `protected init`: an address is set when it is made and never changed afterwards ([DDD00010](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00010), [DDD00011](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00011)). The generator writes the equality and an always-valid twin: ```csharp title="Address.g.cs, shortened" partial record Address : ValueObject, IValidatable { protected override IEnumerable GetEqualityComponents() { yield return Street; yield return City; yield return PostalCode; } public ValidAddress ToValid() => new(this); public virtual Address With(Optional street = default, Optional city = default, Optional postalCode = default) => this with { Street = street.Or(Street), City = city.Or(City), PostalCode = postalCode.Or(PostalCode) }; } public partial record ValidAddress : Address, IAlwaysValid { public ValidAddress(Address value) : base(value) { value.EnsureValidated(); _isValid = true; } } ``` An `Address` may be invalid; it is what a form handed you. A `ValidAddress` cannot be: its only constructor validates. That lets a method say in its signature that it wants a checked address, and the aggregate in the next step does exactly that. See [Value objects](https://dylansnel.github.io/DDDToolkit/docs/value-objects.md). The rules here are a hand-written `Validate` override. If your team writes rules with FluentValidation, a value object's rules can be a FluentValidation validator instead; see [FluentValidation](https://dylansnel.github.io/DDDToolkit/docs/fluent-validation.md). ## Declare an aggregate The order itself. It has an identity, it holds its lines, and it is the one place that decides what may happen to them: ```csharp [AggregateRoot] public partial class Order { public Order(OrderId id, ValidAddress shipTo, IEnumerable lines) : base(id) { ShipTo = shipTo; _lines.AddRange(lines); Status = OrderStatus.Placed; Total = _lines.Aggregate(Money.Zero(), (total, line) => total.Plus(line.Subtotal)); RaiseDomainEvent(new OrderPlaced(id, shipTo, /* the lines */, Total)); } public Address ShipTo { get; private set; } public partial IReadOnlyList Lines { get; } public Money Total { get; private set; } public OrderStatus Status { get; private set; } public void Cancel(string reason) { if (Status is OrderStatus.Cancelled) { return; } Status = OrderStatus.Cancelled; CancellationReason = reason; RaiseDomainEvent(new OrderCancelled(Id, reason)); } } ``` *[`Ordering/Domain/Aggregates/Orders/Order.cs`](https://github.com/DylanSnel/DDDToolkit/blob/main/Examples/Modules/Ordering/DDDToolkit.Examples.Ordering/Domain/Aggregates/Orders/Order.cs)* The constructor takes a `ValidAddress`, so the order never re-validates an address. `Money` is a value object like `Address`, from the shop's shared kernel. `Lines` is declared `partial` and get-only. That is the contract: you describe the property you want, and the generator writes the field behind it. Inside `Order` you add to `_lines`; outside it, callers can read `Lines` but cannot change it. Declaring a setter is an error ([DDD00020](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00020)). ```csharp title="Order.g.cs, shortened" partial class Order : AggregateRoot { protected Order() { } // for Entity Framework and serializers private readonly List _lines = new(); [BackingField(nameof(_lines))] public partial IReadOnlyList Lines => __linesView ??= _lines.AsReadOnly(); // and the invariant checks of the next step } ``` The base class brings the `Id`, a `Version` for optimistic concurrency, and `RaiseDomainEvent`, which is protected, so nothing outside the aggregate can put an event into it. See [Entities and aggregates](https://dylansnel.github.io/DDDToolkit/docs/entities-and-aggregates.md) and [Domain events](https://dylansnel.github.io/DDDToolkit/docs/domain-events.md). ## State an invariant An invariant is a rule about a whole aggregate that must hold every time anyone can look at it. A one-liner goes in the generated seam: ```csharp partial void CheckInvariants() { if (Lines.Select(line => line.Sku).Distinct().Count() != Lines.Count) { throw InvariantViolation("An order may not name the same SKU on two lines."); } } ``` *[`Ordering/Domain/Aggregates/Orders/Order.cs`](https://github.com/DylanSnel/DDDToolkit/blob/main/Examples/Modules/Ordering/DDDToolkit.Examples.Ordering/Domain/Aggregates/Orders/Order.cs)* A rule that deserves a name, or a code a caller can branch on, becomes a type of its own, nested inside the entity it is about so that it can read private state and so the generator can find it: ```csharp public partial class Order { public sealed class MustHaveLines : IInvariant { public string Code => "ORDER_HAS_NO_LINES"; public InvariantFailure? Check(Order order) => order.Lines.Count == 0 ? "An order must have at least one line." : null; } } ``` *[`Ordering/Domain/Aggregates/Orders/Invariants/MustHaveLines.cs`](https://github.com/DylanSnel/DDDToolkit/blob/main/Examples/Modules/Ordering/DDDToolkit.Examples.Ordering/Domain/Aggregates/Orders/Invariants/MustHaveLines.cs)* The generator finds the nested rules and writes the check that runs them all, then the seam, then asks every line: ```csharp title="Order.g.cs, shortened" private static readonly IInvariant[] __invariants = [ new Order.MustHaveLines(), new Order.MustNotCancelAConfirmedOrder(), ]; public override IReadOnlyList GetInvariantViolations() { /* rules, seam, then each line */ } public override void EnsureInvariants() { /* the same, and throws when something is broken */ } ``` `GetInvariantViolations()` asks without throwing, for the moment where "not consistent yet" is an answer you want to handle. `EnsureInvariants()` throws. Asking the root answers for the whole aggregate: its own rules and every line's, each violation naming the entity that reported it. Once the aggregate is stored with Entity Framework, every save runs the check too, so the rules are a guarantee rather than a check somebody remembered to call. An entity that states nothing pays nothing: the compiler erases an unimplemented `partial void` and every call to it. See [Invariants](https://dylansnel.github.io/DDDToolkit/docs/invariants.md). ## Test the aggregate Nothing so far needs a database, and neither does testing it. `DDDToolkit.Testing` acts on an aggregate and asserts on the domain events it raised: ```bash dotnet add package Temp.DDDToolkit.Testing ``` ```csharp var scenario = AggregateScenario.Given(Place()); scenario.IgnorePendingEvents(); scenario.When(order => order.Cancel("No stock.")).RaisedExactly(); scenario.When(order => order.Cancel("Changed my mind.")).RaisedNothing(); ``` Each `When` is judged on what it raised itself, so the second line says what matters about a second cancellation: nothing happens. `WhenThrows` is the same for a call that must fail, and asserts both halves: the exception came out, and nothing was raised on the way out. The aggregate is what calls `new OrderPlaced(...)`, so a test cannot pass an initialiser for the timestamp. `DomainEventClock` replaces the clock the event reads, for the current asynchronous flow only: ```csharp var moment = new DateTimeOffset(2026, 9, 14, 9, 30, 0, TimeSpan.Zero); using var scope = DomainEventClock.Use(new FixedClock(moment)); var order = Place(); order.PendingEvents().Should().OnlyContain(raised => raised.OccurredAt == moment); ``` *[`Tests/DDDToolkit.Examples.Tests/OrderTests.cs`](https://github.com/DylanSnel/DDDToolkit/blob/main/Tests/DDDToolkit.Examples.Tests/OrderTests.cs)* See [Testing](https://dylansnel.github.io/DDDToolkit/docs/testing.md). ## Store it with Entity Framework ```bash dotnet add package Temp.DDDToolkit.EntityFramework ``` The Entity Framework package brings a generator of its own. It writes a value converter for every identifier, so an `OrderId` is stored as a plain `uuid` column, and one method per project that registers them all: ```csharp title="ConverterExtensions.g.cs, shortened" public static ModelConfigurationBuilder AddOrderingConverters(this ModelConfigurationBuilder modelConfigurationBuilder) { modelConfigurationBuilder.Properties().HaveConversion(); modelConfigurationBuilder.DefaultTypeMapping().HasConversion(); // the same two lines for every other identifier and single value object in the project return modelConfigurationBuilder; } ``` The method is named after the project. Set `DDD_Module` in the project file to choose the name; without it the generators use the assembly name with the dots removed, which works but reads poorly: ```xml Ordering ``` It is only a name. Saying that a project is a *module*, with a boundary something checks, is a separate declaration that comes up [further down](https://dylansnel.github.io/DDDToolkit/docs/getting-started.md#draw-the-module-boundary). The context calls that method and the conventions every context shares: ```csharp public sealed class OrderingContext(DbContextOptions options) : DbContext(options) { public DbSet Orders => Set(); protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) { configurationBuilder.AddDDDToolkitConventions(); configurationBuilder.AddOrderingConverters(); } } ``` *[`Ordering/Infrastructure/Persistence/OrderingContext.cs`](https://github.com/DylanSnel/DDDToolkit/blob/main/Examples/Modules/Ordering/DDDToolkit.Examples.Ordering/Infrastructure/Persistence/OrderingContext.cs)* There is no configuration for the domain model itself. `OrderLine` is owned because `[Entity]` generated `[Owned]`, `Lines` is discovered through the generated backing field, `Address` is stored inline because `[ValueObject]` generated `[ComplexType]`, and `Version` is a concurrency token. The example's context has a few more lines; they belong to the modules and the outbox, further down. Register the toolkit and add it to the context: ```csharp builder.Services.AddDDDToolkitEntityFramework(options => options.DispatchWithMediator()); builder.Services.AddDbContext((services, options) => options .UseSqlite(connectionString) .UseDDDToolkit(services)); ``` `UseDDDToolkit` adds the interceptors that deliver domain events, run the invariants and raise the version when the context saves. The argument to `AddDDDToolkitEntityFramework` says how the events are delivered. The example hands them to [Mediator](https://github.com/martinothamar/Mediator) handlers in the same process, which is what `DDDToolkit.Mediator` adds; the outbox, further down, is the other way. An aggregate that raised events refuses to save until one of the two is configured, rather than dropping them. See [Domain event delivery](https://dylansnel.github.io/DDDToolkit/docs/event-delivery.md). Pass the provider the `AddDbContext` callback gives you, not the root provider: it belongs to the same scope as the context, so a handler that injects `OrderingContext` receives the very instance that is saving. See [Entity Framework](https://dylansnel.github.io/DDDToolkit/docs/entity-framework.md). ## Refuse bad input without throwing An endpoint turns a request into an order. The address in the body came from outside and may be junk. `ToValid()` throws, which is right when an invalid value is a bug; at an API boundary it is an ordinary answer to an ordinary request, so use `TryToValid` and hand back the failures: ```csharp app.MapPost("/orders", async (PlaceOrder body, OrderingContext orders, CancellationToken cancellationToken) => { var errors = new List(); if (!new Address(body.Street, body.City, body.PostalCode).TryToValid(out var shipTo, out var addressErrors)) { errors.AddRange(addressErrors.Prefixed("shipTo")); } if (errors.Count > 0) { return Results.ValidationProblem(errors.ToErrorDictionary()); } // ... }); ``` *[`Ordering/Api/OrderingEndpoints.cs`](https://github.com/DylanSnel/DDDToolkit/blob/main/Examples/Modules/Ordering/DDDToolkit.Examples.Ordering/Api/OrderingEndpoints.cs)* The caller gets a 400 it can read field by field, with `shipTo.Street` and `shipTo.PostalCode` naming the fields they filled in. Nothing was thrown, and `shipTo` is the `ValidAddress` the order's constructor asks for. See [Failure handling](https://dylansnel.github.io/DDDToolkit/docs/value-objects.md#failure-handling). ## Handle a concurrency conflict `Version` is incremented on every save that touches the aggregate and checked in the `WHERE` clause, so a stale write becomes a `ConcurrencyConflictException` naming the aggregate: ```csharp try { await orders.SaveChangesAsync(cancellationToken); } catch (ConcurrencyConflictException conflict) { return Results.Conflict(conflict.Message); } ``` *[`Ordering/Api/OrderingEndpoints.cs`](https://github.com/DylanSnel/DDDToolkit/blob/main/Examples/Modules/Ordering/DDDToolkit.Examples.Ordering/Api/OrderingEndpoints.cs)* There is no safe generic answer for that catch block, which is why the toolkit does not retry for you. In the example the conflict is a real one: a customer cancelling an order at the same moment Payments reports the money taken. Whoever saves second is refused. ## Draw the module boundary So far there is one module. The shop has five, and they are only worth having apart if they stay apart: if Shipping may reach into Ordering's aggregates and tables, the two are one module with two names. The toolkit lets you say where the boundary is, and checks it. One assembly, one module: ```csharp [assembly: Module("Ordering")] ``` *[`Ordering/Module.cs`](https://github.com/DylanSnel/DDDToolkit/blob/main/Examples/Modules/Ordering/DDDToolkit.Examples.Ordering/Module.cs)* Nothing happens until a second assembly says it is a module too. From then on, everything an assembly declares is its own business unless it publishes it, and the analyzer reports another module naming an unpublished type ([DDD00022](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00022)) or storing another module's entity ([DDD00023](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00023)). What Ordering publishes is small: its identifier, so the others can point at an order, and the integration events of the next step. The example keeps them in a contracts project of their own, which is the only Ordering project the other modules reference: ```csharp [ModuleContract] [EntityId("ORD")] public readonly partial record struct OrderId; ``` *[`Ordering.Contracts/OrderingContracts.cs`](https://github.com/DylanSnel/DDDToolkit/blob/main/Examples/Modules/Ordering/DDDToolkit.Examples.Ordering.Contracts/OrderingContracts.cs)* `[ModuleContract]` is what publishes it. The contracts project gets converters of its own, so Ordering's context now calls both methods, one per project that declares identifiers: ```csharp configurationBuilder.AddOrderingContractsConverters(); configurationBuilder.AddOrderingConverters(); ``` Why a module publishes anything at all, what belongs in a contract and why it gets a project of its own is explained in [Module contracts](https://dylansnel.github.io/DDDToolkit/docs/module-contracts.md). Both rules are warnings, so that a codebase adopting modules can see the list before it has to fix it. Once the list is empty, hold it: ```xml $(WarningsAsErrors);DDD00022;DDD00023 ``` *[`DDDToolkit.Examples.Shipping.csproj`](https://github.com/DylanSnel/DDDToolkit/blob/main/Examples/Modules/Shipping/DDDToolkit.Examples.Shipping/DDDToolkit.Examples.Shipping.csproj)* See [Modules](https://dylansnel.github.io/DDDToolkit/docs/modules.md). ## Tell another module something happened When an order is confirmed, Shipping has to book a van. Ordering cannot call Shipping, which would put the boundary back, and it should not have to know that Shipping exists at all. It publishes an integration event instead: a contract, separate from the domain event, so the two can change at different speeds: ```csharp [IntegrationEvent] public sealed record OrderPlacedV1( OrderId OrderId, string City, string PostalCode, IReadOnlyList Lines, decimal Total, string Currency); [IntegrationEvent] public sealed record OrderConfirmedV1(OrderId OrderId, string City, string PostalCode); ``` *[`Ordering.Contracts/OrderingContracts.cs`](https://github.com/DylanSnel/DDDToolkit/blob/main/Examples/Modules/Ordering/DDDToolkit.Examples.Ordering.Contracts/OrderingContracts.cs)* Nothing names them: they are published as `ordering.order-placed` and `ordering.order-confirmed`, the module and the class name in kebab case, and the `V1` is their version. The build also writes those names as constants, `OrderingEventNames.OrderPlaced`. See [Stable names](https://dylansnel.github.io/DDDToolkit/docs/domain-events.md#stable-names). One small class per event says how the one becomes the other. It lives next to the aggregate it publishes for: ```csharp public sealed class PublishOrderConfirmed : IOutboundIntegrationEvent { public ValueTask CreateAsync(OrderConfirmed confirmed, CancellationToken cancellationToken) => new(new OrderConfirmedV1(confirmed.OrderId, confirmed.ShipTo.City, confirmed.ShipTo.PostalCode)); } ``` *[`Ordering/Application/Orders/IntegrationEvents/Outbound/`](https://github.com/DylanSnel/DDDToolkit/tree/main/Examples/Modules/Ordering/DDDToolkit.Examples.Ordering/Application/Orders/IntegrationEvents/Outbound)* The message must not be lost if the process stops right after the order is saved, and it must not be sent for an order whose save failed. So it goes through an outbox: a table in Ordering's own database, written in the same transaction as the order. The context maps it: ```csharp protected override void OnModelCreating(ModelBuilder modelBuilder) => modelBuilder.AddDomainEventOutbox(Database, schema: Schema); ``` and Ordering's registration picks up the publishing classes and says that what they make goes to the other modules. It does not say which modules those are: ```csharp services.AddDDDToolkitEntityFramework(options => options.UseOutbox(outbox => { outbox.AddOrderingIntegrationEvents(); // generated when Ordering compiles outbox.SendToModules(); outbox.AlsoDispatchInProcess = true; })); services.AddOutboxBackgroundService(pollingInterval: TimeSpan.FromSeconds(1)); ``` *[`Ordering/OrderingModule.cs`](https://github.com/DylanSnel/DDDToolkit/blob/main/Examples/Modules/Ordering/DDDToolkit.Examples.Ordering/OrderingModule.cs)* `AddOrderingIntegrationEvents()` is generated: it registers every domain event of the module under the name the outbox stores it as, and every publishing class with the contract it makes, so nothing is scanned at start-up. `SaveChanges` now writes the order and one outbox row in one transaction. The background service reads the row afterwards, converts it, and hands it to the other modules. Ordering has already committed by then, which is why a failing consumer cannot refuse an order, and why a concurrency conflict in a consumer is simply retried. In the example, Inventory and Payments pick up `OrderPlacedV1`, answer with contracts of their own, and Ordering confirms the order once both have said yes, or cancels it when either says no. That is the same mechanism in the other direction; `Shipping` waits for `OrderConfirmedV1`. The whole checkout, as the modules tell each other. Every contract goes to every module that handles it; the diagram shows where it matters: ```mermaid sequenceDiagram participant Ordering participant Inventory participant Payments participant Shipping Ordering->>Inventory: OrderPlacedV1 Ordering->>Payments: OrderPlacedV1 Note over Payments: a pending payment alt there is stock Inventory->>Ordering: StockReservedV1 Inventory->>Payments: StockReservedV1 alt the provider takes the money Payments->>Ordering: PaymentSucceededV1 Note over Ordering: stock and money, so confirmed Ordering->>Shipping: OrderConfirmedV1 Note over Shipping: a shipment is booked else the provider refuses Payments->>Ordering: PaymentFailedV1 Note over Ordering: cancelled Ordering->>Inventory: OrderCancelledV1 Note over Inventory: the stock is released end else there is not enough stock Inventory->>Ordering: StockReservationFailedV1 Note over Ordering: cancelled Ordering->>Payments: OrderCancelledV1 Note over Payments: the pending payment is voided end ``` No module calls another, and none waits for an answer: each reacts to what it hears and publishes what happened. The order is where the answers meet. It confirms itself when it has both the stock and the money, in whichever order they arrive.
Show the code: Ordering's side of the checkout One small class per contract Ordering reacts to. It loads the order and tells it what happened; the order decides what that means: ```csharp [IntegrationEventConsumer("ordering.checkout.stock-reserved")] public sealed class RecordStockReservation(OrderingContext context) : IIntegrationEventHandler { public async Task HandleAsync(StockReservedV1 contract, IntegrationEventMessage message, CancellationToken cancellationToken) => (await Checkout.OrderAsync(context, contract.OrderId, cancellationToken)).RecordStockReserved(message.OccurredAt); } [IntegrationEventConsumer("ordering.checkout.payment-failed")] public sealed class CancelWithoutPayment(OrderingContext context) : IIntegrationEventHandler { public async Task HandleAsync(PaymentFailedV1 contract, IntegrationEventMessage message, CancellationToken cancellationToken) => (await Checkout.OrderAsync(context, contract.OrderId, cancellationToken)).Cancel(contract.Reason); } ``` *[`Ordering/Application/Orders/IntegrationEvents/Inbound/Checkout.cs`](https://github.com/DylanSnel/DDDToolkit/blob/main/Examples/Modules/Ordering/DDDToolkit.Examples.Ordering/Application/Orders/IntegrationEvents/Inbound/Checkout.cs)* None of them calls `SaveChanges`. The inbox saves the order together with the row that says the message was applied, and that save writes whatever the order raised, `OrderConfirmed` for instance, into Ordering's outbox in the same transaction. Ordering signs its handlers up in its module registration, with methods the generator writes: ```csharp services.AddDDDToolkitEntityFramework(options => options.MapIntegrationEvents(contracts => contracts.AddOrderingIntegrationEvents())); services.AddModuleIntegrationEvents(module => module.AddOrderingIntegrationEvents()); ``` *[`Ordering/OrderingModule.cs`](https://github.com/DylanSnel/DDDToolkit/blob/main/Examples/Modules/Ordering/DDDToolkit.Examples.Ordering/OrderingModule.cs)*
The order's side of the checkout is a small state machine. It does not care in which order the answers arrive, and an answer that comes too late changes nothing: ```mermaid stateDiagram-v2 [*] --> Placed: new Order(...) Placed --> Placed: RecordStockReserved, or RecordPayment, the first of the two Placed --> Confirmed: the second of the two Placed --> Cancelled: Cancel(reason) Confirmed --> [*] Cancelled --> [*] note right of Confirmed Cancel() here breaks MustNotCancelAConfirmedOrder, so the order cannot be saved that way end note ```
Show the code: the order's methods ```csharp public void RecordStockReserved(DateTimeOffset at) { if (Status is not OrderStatus.Placed || StockReserved) { return; } StockReserved = true; ConfirmWhenReady(at); } public void RecordPayment(DateTimeOffset at) { if (Status is not OrderStatus.Placed || Paid) { return; } Paid = true; ConfirmWhenReady(at); } private void ConfirmWhenReady(DateTimeOffset at) { if (!StockReserved || !Paid) { return; } Status = OrderStatus.Confirmed; ConfirmedAt = at; RaiseDomainEvent(new OrderConfirmed(Id, ShipTo)); } ``` *[`Ordering/Domain/Aggregates/Orders/Order.cs`](https://github.com/DylanSnel/DDDToolkit/blob/main/Examples/Modules/Ordering/DDDToolkit.Examples.Ordering/Domain/Aggregates/Orders/Order.cs)*
The host only switches the modules on, and sets the one thing that is the host's: how domain events that stay inside a module are published. ```csharp builder.Services.AddDDDToolkitEntityFramework(options => options.DispatchWithMediator()); builder.Services.AddCatalogModule(supabase); builder.Services.AddOrderingModule(supabase); builder.Services.AddInventoryModule(supabase); builder.Services.AddPaymentsModule(supabase); builder.Services.AddShippingModule(supabase); ``` *[`Host/Program.cs`](https://github.com/DylanSnel/DDDToolkit/blob/main/Examples/ModularMonolith.Supabase/DDDToolkit.Examples.Host/Program.cs)* ## Consume it once ```csharp [IntegrationEventConsumer("shipping.booker")] public sealed class BookShipment(ShippingContext context) : IIntegrationEventHandler { public Task HandleAsync(OrderConfirmedV1 contract, IntegrationEventMessage message, CancellationToken cancellationToken) { context.Shipments.Add(new Shipment( ShipmentId.CreateSequential(), contract.OrderId, $"{contract.PostalCode}, {contract.City}", message.OccurredAt)); return Task.CompletedTask; } } ``` *[`Shipping/Application/Shipments/IntegrationEvents/Inbound/BookShipment.cs`](https://github.com/DylanSnel/DDDToolkit/blob/main/Examples/Modules/Shipping/DDDToolkit.Examples.Shipping/Application/Shipments/IntegrationEvents/Inbound/BookShipment.cs)* Three things there are the point. It is typed on the contract, never on Ordering's domain event, which is what keeps Shipping free of a reference to Ordering's domain. It does not call `SaveChanges`: the sink runs it inside the inbox, so the shipment and the row that says this consumer applied this message are written by one save in one transaction. And the consumer name is what the inbox keys on, so delivery twice does the work once. Shipping signs itself up as a consumer, with the contracts it reads and the handlers that run under its inbox: ```csharp services.AddDDDToolkitEntityFramework(options => options.MapIntegrationEvents(contracts => contracts.RegisterFromAssemblyContaining())); services.AddModuleIntegrationEvents(module => module.Handle()); ``` *[`Shipping/ShippingModule.cs`](https://github.com/DylanSnel/DDDToolkit/blob/main/Examples/Modules/Shipping/DDDToolkit.Examples.Shipping/ShippingModule.cs)* and maps the inbox table in its context: ```csharp protected override void OnModelCreating(ModelBuilder modelBuilder) => modelBuilder.AddDomainEventInbox(Database, schema: Schema); ``` *[`Shipping/Infrastructure/Persistence/ShippingContext.cs`](https://github.com/DylanSnel/DDDToolkit/blob/main/Examples/Modules/Shipping/DDDToolkit.Examples.Shipping/Infrastructure/Persistence/ShippingContext.cs)* `schema: Schema` puts the table in the module's own schema instead of the toolkit's default `ddd`. It matters as soon as modules share a database, as they do on one Supabase project: every module that consumes needs an inbox and every module that publishes an outbox, and in one shared `ddd` schema they would all be the same two tables. See [Integration events](https://dylansnel.github.io/DDDToolkit/docs/integration-events.md). ## See the generated code Nothing here is magic, and reading the output is the fastest way to understand it. Go to definition on a generated member opens the file it is in, or have the files written to disk: ```xml true $(MSBuildProjectDirectory)\Generated ``` Build, then look in `Generated/`. Add that folder to `.gitignore`. [What the generator writes](https://dylansnel.github.io/DDDToolkit/docs/generated-code.md) walks through that output for a small aggregate, file by file. Each file is named after the type it belongs to, the generator that wrote it and a short hash that keeps two types of the same name apart, such as `Order.2c9e41f0.g.cs` for the entity itself and `Order.EntityFramework.5b17d3aa.g.cs` for its Entity Framework part (the hash depends on the namespace). The namespace itself is left out of the name on purpose. It is already in the folder, and Visual Studio has to fit `Generated\{generator assembly}\{generator}\{file}` under your project folder into 260 characters. ## When something does not generate Every misuse reports an error with an identifier starting `DDD`. If a type you annotated produced no code, check the build output first: the generator tells you what is wrong and which line to fix. See [Diagnostics](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md) for the full list. ## Run the example ```bash dotnet run --project Examples/ModularMonolith.Supabase/DDDToolkit.Examples.Host ``` Then work through [`DDDToolkit.Examples.Host.http`](https://github.com/DylanSnel/DDDToolkit/blob/main/Examples/ModularMonolith.Supabase/DDDToolkit.Examples.Host/DDDToolkit.Examples.Host.http) from the top. [`Examples/README.md`](https://github.com/DylanSnel/DDDToolkit/blob/main/Examples/README.md) is the map of the folder and says which file shows what, and how to run the same host on a local Supabase instead of SQLite. # What the generator writes Every attribute in DDDToolkit is a request to a Roslyn source generator. The generator reads your declaration while the project compiles and adds C# files to the same compilation. There is no runtime library looking at your types, no reflection over them and nothing woven into the IL. What runs is the code on this page, and you can open it, read it and step through it like your own. ```mermaid flowchart LR You["your partial types, with an attribute"] --> Core["the DDDToolkit generator"] You --> EF["the Entity Framework generator"] You --> HC["the HotChocolate generator"] Core --> CoreOut["base types, identifiers, equality, the always-valid twin, invariant checks"] EF --> EFOut["value converters, ComplexType and Owned, the converter and integration event registrations"] HC --> HCOut["type converters, Relay node id serializers, the GraphQL bindings"] ```
Show the code: turning the generators on Each package carries its generator, so referencing it is all there is to it: ```bash dotnet add package Temp.DDDToolkit # base types and the core generator dotnet add package Temp.DDDToolkit.EntityFramework # its generator: the mapping dotnet add package Temp.DDDToolkit.HotChocolate # its generator: the GraphQL bindings ``` The registration methods the generators write are named after the project. Choose the name with `DDD_Module`: ```xml Shop ```
This page walks through that output for one small aggregate. The code comes from [`website/sample`](https://github.com/DylanSnel/DDDToolkit/tree/main/website/sample), which the docs site compiles to show the same files on its homepage, so it is the generators' real output. The only change is that `global::` prefixes have been taken out and long files are shortened. Four files go in: ```csharp [AggregateRoot("ORD")] public partial class Order { public Order(OrderId id, Address shipTo) : base(id) { ShipTo = shipTo; RaiseDomainEvent(new OrderPlaced(id)); } public Address ShipTo { get; private set; } public partial IReadOnlyList Lines { get; } public sealed class MustHaveLines : IInvariant { public string Code => "ORDER_HAS_NO_LINES"; public InvariantFailure? Check(Order order) => order.Lines.Count == 0 ? "An order has at least one line." : null; } } [Entity("LINE")] public partial class OrderLine { /* Sku and Quantity */ } [ValueObject] public partial record Address(string Street, string City) { protected override bool Validate() => Street.Length > 0 && City.Length > 0; } [DomainEventName("shop.order-placed")] public sealed record OrderPlaced(OrderId Order) : DomainEvent; ``` 58 lines in, 963 lines out, in 15 files. ## Why generate it - **It is there, and it is current.** Add a property to a value object and its equality, its `With()` and its mapping follow at the next build. There is no second file that has to be kept in step, and no reviewer has to check that it was. - **It costs what hand-written code costs.** Equality, conversions and invariant checks call your members directly. [Performance](https://dylansnel.github.io/DDDToolkit/docs/performance.md) measures a struct id against the raw `Guid` it wraps, and they come out the same. - **You pay only for what you use.** An entity without rules gets no list of rules and no check to run. An unimplemented `partial void CheckInvariants()` is removed by the compiler, with every call to it. - **Misuse is a compile error.** When a declaration cannot be generated, or does something other than it looks like, you get a [diagnostic](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md) in the editor instead of a type that quietly behaves like a plain class. - **One declaration reaches every integration.** Reference `DDDToolkit.EntityFramework` or `DDDToolkit.HotChocolate` and their generators add converters and bindings for the same types. Your declaration does not change. ## `[AggregateRoot]` and `[Entity]` `Order.g.cs` gives the class its base type, with the identifier type the attribute asked for, and the constructor Entity Framework needs to materialize a row: ```csharp title="Order.g.cs, shortened" partial class Order : DDDToolkit.BaseTypes.AggregateRoot { /// Parameterless constructor for persistence frameworks and serializers. protected Order() { } ``` Every rule nested in the class is created once and kept in a static array. The checks walk it, then the `CheckInvariants()` seam, then every child entity the aggregate holds in a collection: ```csharp title="Order.g.cs, shortened" private static readonly DDDToolkit.Invariants.IInvariant[] __invariants = [ new Shop.Order.MustHaveLines(), ]; public override void EnsureInvariants() { System.Collections.Generic.List? violations = null; CollectInvariantViolations(ref violations, out var seamFailure); CollectChildInvariantViolations(ref violations); if (violations is null) { return; } ThrowInvariantViolations(violations, seamFailure); } ``` The list of violations is created on the first failure, so a consistent aggregate allocates nothing. [Invariants](https://dylansnel.github.io/DDDToolkit/docs/invariants.md) explains the two stages, `GetInvariantViolations()` to ask and `EnsureInvariants()` to throw, and when the save runs them. The `partial` collection property gets a field behind it: ```csharp title="Order.g.cs, shortened" private readonly System.Collections.Generic.List _lines = new(); private System.Collections.Generic.IReadOnlyList? __linesView; /// Read-only view over . Mutate the collection through the field. [Microsoft.EntityFrameworkCore.BackingField(nameof(_lines))] public partial System.Collections.Generic.IReadOnlyList Lines => __linesView ??= _lines.AsReadOnly(); } ``` In practice: code inside `Order` adds a line with `_lines.Add(...)`, and code outside it can read `Lines` but cannot change it. Entity Framework loads and saves the field. This is the usual hand-written pattern for keeping a collection inside its aggregate, minus the writing. `OrderLine.g.cs` is the same for the child entity, with `Entity` as the base class. ## The identifier `[AggregateRoot("ORD")]` names the id type after the class, so the generator writes `OrderId`. No `OrderId.cs` exists anywhere. It is a `readonly record struct` around the `Guid`: ```csharp title="OrderId.g.cs, shortened" [System.Text.Json.Serialization.JsonConverter(typeof(OrderId.SystemTextJsonConverter))] public readonly partial record struct OrderId : DDDToolkit.Abstractions.Interfaces.IEntityId, System.IComparable, System.IParsable { public const string IdPrefix = "ORD"; public System.Guid Value { get; } public static OrderId CreateUnique() => new(System.Guid.NewGuid()); public static OrderId CreateSequential() => new(System.Guid.CreateVersion7()); public override string ToString() => /* ORD_1b4e28ba-2fa1-11d2-883f-0016d3cca427 */; public static OrderId Parse(string input) { /* the prefix is optional */ } public static bool TryParse(string? input, out OrderId result) { /* ... */ } public sealed class SystemTextJsonConverter : System.Text.Json.Serialization.JsonConverter { /* ... */ } } ``` `CreateSequential()` makes a version 7 `Guid`, which is ordered by time and friendlier to a database index than a random one. [Identifiers](https://dylansnel.github.io/DDDToolkit/docs/identifiers.md) covers the other value types, the record form and the prefix. ## `[ValueObject]` `Address.g.cs` gives the record its base type and equality over its components, in declaration order: ```csharp title="Address.g.cs, shortened" partial record Address : DDDToolkit.BaseTypes.ValueObject, DDDToolkit.Validation.IValidatable { [System.Text.Json.Serialization.JsonInclude] public string Street { get; protected init; } = Street; [System.Text.Json.Serialization.JsonInclude] public string City { get; protected init; } = City; protected override System.Collections.Generic.IEnumerable GetEqualityComponents() { yield return Street; yield return City; } ``` The positional parameters become properties with a `protected init` setter, so nobody outside the record can make a changed copy with `with` and skip validation. What it offers instead is `With()`, which copies with changes and judges the copy afresh: ```csharp title="Address.g.cs, shortened" public virtual Address With(DDDToolkit.BaseTypes.Optional street = default, DDDToolkit.BaseTypes.Optional city = default) => this with { Street = street.Or(Street), City = city.Or(City) }; } ``` It also writes `ValidAddress`, the always-valid twin. Its only constructor validates, so a method that takes a `ValidAddress` never has to check one again: ```csharp title="Address.g.cs, shortened" public partial record ValidAddress : Address, DDDToolkit.Abstractions.Interfaces.IAlwaysValid { public ValidAddress(Address value) : base(value) { value.EnsureValidated(); _isValid = true; } ``` [Value objects](https://dylansnel.github.io/DDDToolkit/docs/value-objects.md) explains validation, the twin and `With()` in full. ## The event names Every name the project's events are stored or published under becomes a constant, in a class named after the module: ```csharp title="EventNames.g.cs, shortened" public static class ShopEventNames { /// shop.order-placed: (version 1). public const string OrderPlaced = "shop.order-placed"; } ``` `OrderPlaced` pins its name with `[DomainEventName]`. Without it the name would be the module and the class name in kebab case; [Stable names](https://dylansnel.github.io/DDDToolkit/docs/domain-events.md#stable-names) has the rule, and the checks the same pass runs on it. ## `DDDToolkit.EntityFramework` With the Entity Framework package referenced, its generator adds the mapping. Each id gets a value converter that stores it as its plain value: ```csharp title="OrderId.Converter.g.cs" public readonly partial record struct OrderId { public sealed class OrderIdConverter : Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter { public OrderIdConverter() : base(static v => v.Value, static v => new Shop.OrderId(v)) { } } } ``` One method registers every converter in the project. It is named after the module, set with `Shop` in the project file, and you call it from `ConfigureConventions`: ```csharp title="ConverterExtensions.g.cs, shortened" public static Microsoft.EntityFrameworkCore.ModelConfigurationBuilder AddShopConverters(this Microsoft.EntityFrameworkCore.ModelConfigurationBuilder modelConfigurationBuilder) { modelConfigurationBuilder.Properties().HaveConversion(); modelConfigurationBuilder.DefaultTypeMapping().HasConversion(); // the same for OrderLineId return modelConfigurationBuilder; } ``` Value objects are marked `[ComplexType]`, so their fields become columns of the table that holds them. Child entities are marked `[Owned]`, so they are saved with their aggregate and never alone. `AddShopIntegrationEvents()` registers every domain event of the module with the outbox, under its stable name, here the one from `[DomainEventName]`: ```csharp title="IntegrationEventExtensions.g.cs, shortened" outbox.RegisterEvent("shop.order-placed", 1); ``` The name is what the outbox stores, so renaming the class does not orphan rows already written. See [Entity Framework](https://dylansnel.github.io/DDDToolkit/docs/entity-framework.md) and [Integration events](https://dylansnel.github.io/DDDToolkit/docs/integration-events.md). ## `DDDToolkit.HotChocolate` With the HotChocolate package referenced, each id gets a type converter and a Relay node id serializer, and one method binds them all: ```csharp title="BindingExtensions.g.cs, shortened" public static HotChocolate.Execution.Configuration.IRequestExecutorBuilder AddShopGraphQlRuntimeBindings(this HotChocolate.Execution.Configuration.IRequestExecutorBuilder builder) { builder.BindRuntimeType(); builder.AddTypeConverter(); builder.AddNodeIdValueSerializer(); // the same for OrderLineId return builder; } ``` An `OrderId` is a `UUID` in the schema and can be the key inside a Relay node id. See [GraphQL](https://dylansnel.github.io/DDDToolkit/docs/graphql.md). ## See it in your own project Go to definition on a generated member opens the generated file, and in Visual Studio the files are also listed under **Dependencies → Analyzers**. To have them written to disk: ```xml true $(MSBuildProjectDirectory)\Generated ``` Build, then look in `Generated/`, and add that folder to `.gitignore`. If a type you annotated produced nothing, the build output has a `DDD` diagnostic that says why; see [Diagnostics](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md). # Identifiers An order has an id, and so does the customer who placed it. If both are a `Guid`, they are the same type to the compiler: a method that takes an order id accepts a customer id just as happily, and a call with its arguments the wrong way round compiles, runs, and finds nothing. ```csharp public Task Assign(Guid orderId, Guid customerId); // Assign(customer.Id, order.Id) compiles public Task Assign(OrderId orderId, CustomerId customerId); // the same mistake does not ``` A strongly typed identifier stops you passing a customer id where an order id belongs. Written by hand, each one needs a constructor, formatting, parsing, comparison and a converter for every serializer and database it meets. `[EntityId]` generates all of that from a single declaration: ```csharp [EntityId("ORD")] public readonly partial record struct OrderId; ``` The declaration has to be `partial`, so the generator can add to it, and a record. Use `readonly partial record struct` unless you have a reason not to; there is a class form as well, and [Struct or record](https://dylansnel.github.io/DDDToolkit/docs/identifiers.md#struct-or-record) says when it is worth it. This page starts with what the struct form gives you, then creating, printing and parsing identifiers, then letting an entity declare its own, and ends with the class form, storage, JSON and GraphQL, and the rules a declaration must follow. ## What the struct form generates The generator writes the other half of the type while the project compiles. For `OrderId` it writes: ```csharp title="OrderId.g.cs, shortened" [JsonConverter(typeof(OrderId.SystemTextJsonConverter))] readonly partial record struct OrderId : IEntityId, IComparable, IParsable { public const string IdPrefix = "ORD"; public Guid Value { get; } public OrderId(Guid value) { Value = value; } public static OrderId Empty => default; public bool IsEmpty => EqualityComparer.Default.Equals(Value, default); public static OrderId CreateUnique() => new(Guid.NewGuid()); #if NET9_0_OR_GREATER public static OrderId CreateSequential() => new(Guid.CreateVersion7()); #endif public override string ToString() => IdPrefix.Length == 0 ? string.Create(CultureInfo.InvariantCulture, $"{Value}") : string.Create(CultureInfo.InvariantCulture, $"{IdPrefix}_{Value}"); public int CompareTo(OrderId other) => Comparer.Default.Compare(Value, other.Value); public static explicit operator Guid(OrderId id) => id.Value; public static explicit operator OrderId(Guid value) => new(value); public static OrderId Parse(string input) { /* ... */ } public static bool TryParse([NotNullWhen(true)] string? input, out OrderId result) { /* ... */ } // ... public sealed class SystemTextJsonConverter : JsonConverter { /* ... */ } } ``` Your half carries the accessibility and the attribute; the generated half carries the members. That is why `public` appears once, on the declaration you write, and why that declaration stays one line. The type implements `IEntityId`, `IComparable` and `IParsable`, and carries a `[JsonConverter]` pointing at a generated nested converter, so `System.Text.Json` writes it as the bare value and reads it back; see [JSON and GraphQL](https://dylansnel.github.io/DDDToolkit/docs/identifiers.md#json-and-graphql). Record struct equality comes from the language. Conversions are explicit on purpose. An implicit conversion would undo the type safety you asked for by letting a raw `Guid` flow in wherever an `OrderId` is expected. [See the generated code](https://dylansnel.github.io/DDDToolkit/docs/getting-started.md#see-the-generated-code) says how to open these files in your own project. ## Creating identifiers ```csharp var id = OrderId.CreateUnique(); // Guid.NewGuid() var id = OrderId.CreateSequential(); // Guid.CreateVersion7(), .NET 9 and later ``` Prefer `CreateSequential` for anything you store. Version 7 identifiers embed a timestamp and sort roughly in creation order, which keeps database indexes from fragmenting the way random identifiers do. Both are generated only for `Guid`; for other value types you construct the id yourself, with the public constructor. ## Prefixes The optional first argument prefixes the textual form: ```csharp [EntityId("ORD")] // ToString() → "ORD_2f1c8e9a-..." [EntityId] // ToString() → "2f1c8e9a-..." ``` `Parse` and `TryParse` accept the text with or without the prefix, so an id that crossed a system boundary in either shape still round trips. The prefix is exposed as the `IdPrefix` constant. Prefixes are for humans reading logs, URLs and support tickets. The stored value is the underlying `Guid`; the prefix is not persisted. ## Parsing `Parse` and `TryParse` are generated whenever the wrapped type can be parsed: `string`, or any type with a static `TryParse(string, IFormatProvider, out T)`. That covers the numeric types, `Guid`, `DateTime`, `DateOnly` and friends. Parsing uses the invariant culture, so an id written on one machine reads back on another. Wrap a type without such a method and the identifier still generates, just without the parsing members and without `IParsable`. ```csharp var id = OrderId.Parse("ORD_2f1c8e9a-..."); // throws FormatException when malformed if (OrderId.TryParse(candidate, out var parsed)) // false when malformed or null { } ``` `TryParse` does the work and `Parse` calls it. The generated body is where the optional prefix and the invariant culture come from: ```csharp title="OrderId.g.cs, shortened" public static bool TryParse([NotNullWhen(true)] string? input, out OrderId result) { result = default; if (input is null) { return false; } if (IdPrefix.Length != 0 && input.StartsWith(IdPrefix + "_", StringComparison.Ordinal)) { input = input.Substring(IdPrefix.Length + 1); } if (!Guid.TryParse(input, CultureInfo.InvariantCulture, out var value)) { return false; } result = new(value); return true; } ``` Because the struct form implements `IParsable`, it also works with generic code and with ASP.NET Core minimal API route and query binding, which binds through `IParsable` and `TryParse`. MVC controllers bind a route or query parameter typed as an identifier through the same static `TryParse`, so they need nothing extra either. ## The empty value A struct has no null, so `default(OrderId)` exists and wraps `Guid.Empty`. The generator makes that explicit rather than leaving it as a trap, with the `Empty` and `IsEmpty` members shown above: ```csharp OrderId id = default; id.IsEmpty; // true id == OrderId.Empty; // true ``` Guard on `IsEmpty` where a reference id would have been null-checked. Where you genuinely want an optional id, use `OrderId?`; it stays off the heap. ## Letting the entity declare the id Most identifiers exist only to identify one entity, and declaring them separately says the same thing twice. Name the raw value on the entity instead and the toolkit generates the identifier too: ```csharp [AggregateRoot("ORD")] public partial class Order { } // also generates OrderId ``` The generator gives the entity its base class, typed on an identifier it writes as well: ```csharp title="Order.g.cs, shortened" partial class Order : AggregateRoot { protected Order() { } // ... } ``` ```csharp title="OrderId.g.cs, shortened" [JsonConverter(typeof(OrderId.SystemTextJsonConverter))] public readonly partial record struct OrderId : IEntityId, IComparable, IParsable { public const string IdPrefix = "ORD"; public Guid Value { get; } // ... } ``` That is the same as writing both of these: ```csharp [EntityId("ORD")] public readonly partial record struct OrderId; [AggregateRoot] public partial class Order { } ``` The generated identifier is named after the entity with `Id` appended, so `Order` gets `OrderId` and `OrderLine` gets `OrderLineId`. It is a `readonly partial record struct` written by the same emitter as an explicit struct identifier, so it has the same members, the same interfaces, the same JSON converter, the same Entity Framework value converter and the same GraphQL binding. It lands in the same namespace as the entity, or inside the same containing type when the entity is nested. `[Entity]` works the same way. `Prefix` means what it means on `[EntityId]`, and can be passed by name: ```csharp [AggregateRoot(Prefix: "SKU")] public partial class Product { } ``` There is no default prefix. An identifier without one prints its bare value, exactly as `[EntityId]` without a prefix does. The toolkit does not invent one from the type name: a prefix ends up in logs, URLs and support tickets, so it is a decision to make once and keep, not something that should change the day the class is renamed. The identifier is `partial`, so you can still add members to it from a file of your own, and attributes such as `[GraphQLType]` with them: ```csharp public readonly partial record struct OrderId { public string Short => Value.ToString("N")[..8]; } ``` Your part needs no accessibility modifier; it takes the one the generated part states. It must be a `partial record struct`, and it must not carry `[EntityId]`, because that would declare a second identifier of the same name. Either of those reports [DDD00007](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00007). ### When not to use it The identifier has no declaration site of its own. There is no line to put the cursor on, nothing to "go to definition" on except generated code, and nothing to hang XML documentation from. That is a fair trade for an identifier only its own aggregate ever mentions, and a bad one for an identifier that other aggregates, DTOs, API contracts or message schemas refer to. Those are types in their own right, read by people who never open the aggregate, and they deserve a declaration you can find and comment on. So: the short form for the identifier nobody talks about, and the explicit form for the identifier everybody does. Moving from one to the other is a two-line change in either direction, and nothing about the generated identifier changes with it. One limit either way: the type argument must be a value type or a `string`. Anything else reports [DDD00008](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00008). ## Struct or record Both are supported. They behave the same and they do not cost the same. | | `readonly partial record struct` | `partial record` | |---|---|---| | Allocation | None. The id is its value. | One object per id on the heap, 64 bytes. | | Base type | None. Implements `IEntityId`. | Derives from `EntityId`. | | Equality and hashing | Written by the compiler. Free. | Compares `Value` directly. Free, plus a dereference. | | Absent value | `default`, exposed as `Empty`/`IsEmpty` | `null` | | Always-valid twin | No | Yes, `Valid` | | Inheritance | Not possible | Possible | **Prefer the struct.** An identifier is a value, and the struct form is what `readonly record struct` exists for. A `Guid`-based struct id is sixteen bytes, the same as the `Guid` it wraps. The record form costs sixty-four: an eight-byte reference, an object header, the `Guid`, the prefix, and the two validation fields it inherits as a [value object](https://dylansnel.github.io/DDDToolkit/docs/value-objects.md#validation) and no identifier ever reads. Every read of a record id pays a dereference, which measures at about 20%, and a dictionary lookup costs about half as much again. Neither form allocates when it compares or hashes. Entity Framework is not a reason either way; see [Entity Framework](https://dylansnel.github.io/DDDToolkit/docs/identifiers.md#entity-framework) below. [Performance](https://dylansnel.github.io/DDDToolkit/docs/performance.md) has the measurements behind all of this. Reach for `partial record` only when you need inheritance or the [always-valid twin](https://dylansnel.github.io/DDDToolkit/docs/value-objects.md#the-always-valid-twin). Neither is common for identifiers: validation belongs to value objects, and an id is either well-formed or it is not. The struct form could not have a twin in any case: the twin derives from the type it validates, and a struct cannot be derived from. Nor does it need one. A struct id does not derive from `ValueObject` and has no rules to run, so it is well formed by construction, and it has no `TryToValid` or `TryValidate` either. ## The record form ```csharp [EntityId("CUST")] public partial record CustomerId { public static CustomerId Create(Guid value) => new(value); } ``` This derives from `EntityId`, which supplies `Value` and the prefixed `ToString`. The generator adds the constructors, equality over `Value`, `Parse`/`TryParse`, `CreateUnique` and `CreateSequential` for `Guid`, and a `ValidCustomerId` twin reachable through `ToValid()`. The twin is the same one every value object gets; [Value objects](https://dylansnel.github.io/DDDToolkit/docs/value-objects.md#the-always-valid-twin) explains what it is for. ```csharp title="CustomerId.g.cs, shortened" partial record CustomerId : EntityId, IValidatable { public const string IdPrefix = "CUST"; protected CustomerId(Guid value) : base(value, IdPrefix) { } [JsonConstructor] protected CustomerId() : base(IdPrefix) { } public virtual bool Equals(CustomerId? other) { // ... return EqualityComparer.Default.Equals(Value, other.Value); } public override int GetHashCode() => EqualityComparer.Default.GetHashCode(Value); // CreateUnique, CreateSequential, Parse and TryParse, as on the struct form public ValidCustomerId ToValid() => new(this); } public partial record ValidCustomerId : CustomerId, IAlwaysValid { // ... } ``` The constructors are `protected`, so a public factory like `Create` above is the usual pattern. What you do not get, compared with the struct form, is `Empty`/`IsEmpty`, the explicit conversion operators and `IComparable`. A reference type has `null` for the absent value and no need for a conversion that a cast already expresses. Equality is generated as a direct comparison of `Value`, not inherited from `ValueObject`, so it allocates nothing and boxes nothing. It is still a call through a reference, which costs about half as much again as the struct form; see [Struct or record](https://dylansnel.github.io/DDDToolkit/docs/identifiers.md#struct-or-record) above and [Performance](https://dylansnel.github.io/DDDToolkit/docs/performance.md#dictionary-and-set-lookup) for the measurement. ## Entity Framework Everything above works without a database. Reference `DDDToolkit.EntityFramework` and its generator adds a value converter to every identifier, which stores the identifier as its plain value, so an `OrderId` is a `Guid` column and not a serialized object: ```csharp title="OrderId.Converter.g.cs" readonly partial record struct OrderId { public sealed class OrderIdConverter : ValueConverter { public OrderIdConverter() : base(static v => v.Value, static v => new OrderId(v)) { } } } ``` and one method per project that registers every converter in it. You call that method from `ConfigureConventions`, and it is named after the project, here `Shop`: ```csharp title="ConverterExtensions.g.cs, shortened" public static ModelConfigurationBuilder AddShopConverters(this ModelConfigurationBuilder modelConfigurationBuilder) { modelConfigurationBuilder.Properties().HaveConversion(); modelConfigurationBuilder.DefaultTypeMapping().HasConversion(); // ... return modelConfigurationBuilder; } ``` [Entity Framework](https://dylansnel.github.io/DDDToolkit/docs/entity-framework.md) says how the name is chosen, why each converter is registered twice, and how to wire the method into a context. The record form gets the same converter, and one for its always-valid twin. Both forms map to the same provider column, and both round trip at the same speed: the database work is orders of magnitude larger than the difference between them. The struct form saves one object per row, which disappears into what materialising a row costs anyway. See [Performance](https://dylansnel.github.io/DDDToolkit/docs/performance.md#the-entity-framework-round-trip). ### Column length ```csharp [EntityId(Prefix: "SKU", ColumnLength: 32)] public readonly partial record struct Sku; ``` `ColumnLength` is for Entity Framework only. Without the `DDDToolkit.EntityFramework` package it does nothing, and it never validates: a maximum length a value must respect is a rule you state yourself. With the package, it flows into the generated configuration as `HaveMaxLength`, on the registration of the property and of the type mapping: ```csharp title="ConverterExtensions.g.cs, shortened" modelConfigurationBuilder.Properties().HaveConversion().HaveMaxLength(32); modelConfigurationBuilder.DefaultTypeMapping().HasConversion().HasMaxLength(32); ``` An entity that [declares its own id](https://dylansnel.github.io/DDDToolkit/docs/identifiers.md#letting-the-entity-declare-the-id) takes it by name too: ```csharp [AggregateRoot(Prefix: "SKU", ColumnLength: 32)] public partial class Product { } ``` ## JSON and GraphQL The JSON converter needs no package: it is part of the struct form's own generated code, attached by the `[JsonConverter]` attribute on the type. It writes the identifier as its bare value, so an `OrderId` in a response body is a plain string holding the `Guid`, with no prefix and no wrapping object. As a dictionary key it is written through `ToString()`, prefix included, and read back through `Parse`: ```csharp title="OrderId.g.cs, shortened" [JsonConverter(typeof(OrderId.SystemTextJsonConverter))] readonly partial record struct OrderId : IEntityId, IComparable, IParsable { // ... public sealed class SystemTextJsonConverter : JsonConverter { public override OrderId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { var value = JsonSerializer.Deserialize(ref reader, options); return new(value); } public override void Write(Utf8JsonWriter writer, OrderId value, JsonSerializerOptions options) => JsonSerializer.Serialize(writer, value.Value, options); public override OrderId ReadAsPropertyName(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => Parse(reader.GetString() ?? throw new JsonException("Cannot convert null to OrderId.")); public override void WriteAsPropertyName(Utf8JsonWriter writer, OrderId value, JsonSerializerOptions options) => writer.WritePropertyName(value.ToString()); } } ``` With `DDDToolkit.HotChocolate` referenced, its generator nests two more classes in every identifier: a `ChangeTypeProvider`, which converts between the identifier and the value it wraps, and a `NodeIdValueSerializer`, which lets the identifier be the key inside a Relay node id. One generated method per project registers them: ```csharp title="BindingExtensions.g.cs, shortened" public static IRequestExecutorBuilder AddShopGraphQlRuntimeBindings(this IRequestExecutorBuilder builder) { // ... builder.BindRuntimeType(); builder.AddTypeConverter(); builder.AddNodeIdValueSerializer(); return builder; } ``` An `OrderId` is then a `UUID` in the schema, and like the JSON form it carries no prefix. [GraphQL](https://dylansnel.github.io/DDDToolkit/docs/graphql.md) covers the bindings, input objects and node ids in full. ## Requirements The declaration must be `partial` and must be a record. A plain `class` or `struct` carrying `[EntityId]` reports [DDD00003](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00003). A record struct that is not `readonly` reports [DDD00004](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00004) as a warning and still generates; making it readonly prevents mutation and avoids defensive copies. A sealed record reports [DDD00013](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00013), because the always-valid twin has to derive from it. # Value objects An email address passed around as a `string` can be any string. Every method that receives one has to decide whether to trust it, a customer's email and a supplier's name can be swapped without the compiler noticing, and the rule for what makes an email address valid ends up copied wherever somebody remembered it. A value object gives the value a type of its own. It has no identity: two instances with the same contents are the same thing, the way two ten-euro notes are the same amount. It carries its own rules, so they live in one place, and a method can say in its signature that it wants a valid one. The toolkit has two kinds: one for a single wrapped value, such as an email address, and one for a cluster of properties, such as an address or an amount of money. This page starts with both, then adds validation, the always-valid twin and changing a value, and ends with storage and the other integrations. ## A single value ```csharp [SingleValueObject] public partial record EmailAddress { public static EmailAddress Create(string value) => new(value); } ``` The record has to be `partial`, so the generator can add to it. It derives the record from `SingleValueObject`, which supplies the `Value` property, and writes the equality over it, the constructors and `ToValid()`: ```csharp title="EmailAddress.g.cs, shortened" partial record EmailAddress : SingleValueObject, IValidatable { public virtual bool Equals(EmailAddress? other) { // ... null, type and reference checks return EqualityComparer.Default.Equals(Value, other.Value); } public override int GetHashCode() => Value is null ? 0 : EqualityComparer.Default.GetHashCode(Value); protected EmailAddress(string value) : base(value) { } [JsonConstructor] protected EmailAddress() { } public ValidEmailAddress ToValid() => new(this); } ``` `ToValid()` and `ValidEmailAddress` come up under [validation](https://dylansnel.github.io/DDDToolkit/docs/value-objects.md#the-always-valid-twin). Using it: ```csharp var email = EmailAddress.Create("dylan@example.com"); email.Value; // "dylan@example.com" email == EmailAddress.Create("dylan@example.com"); // true: same contents, same value ``` Constructors are `protected`, so expose a factory like `Create` above. That keeps one obvious entry point and leaves room to validate. ## Several properties ```csharp [ValueObject] public partial record PersonName { public PersonName(string firstName, string lastName) => (FirstName, LastName) = (firstName, lastName); public string FirstName { get; protected init; } public string LastName { get; protected init; } } ``` Equality is generated from the properties, in declaration order: two names are equal when the first and the last name match. ```csharp title="PersonName.g.cs, shortened" partial record PersonName : ValueObject, IValidatable { [Internal] protected override IEnumerable GetEqualityComponents() { yield return FirstName; yield return LastName; } public virtual bool Equals(PersonName? other) { // ... null and type checks return Enumerable.SequenceEqual(GetEqualityComponents(), other.GetEqualityComponents()); } // GetHashCode over the same components, a constructor for JSON, ToValid() and With(...) } ``` Add a property and it is in `GetEqualityComponents()` at the next build. There is no list of members to keep in step by hand, which is the part of a hand-written value object that goes wrong. Properties you declare yourself are `{ get; protected init; }`. Anything else reports [DDD00010](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00010) or [DDD00011](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00011), and a code fix turns it into `protected init`. The short reason is that a value is set when it is made and never changed afterwards; the long one is in [Why `with` is closed to callers](https://dylansnel.github.io/DDDToolkit/docs/value-objects.md#why-with-is-closed-to-callers). A value object with little behaviour can be a positional record instead, and the generator takes care of the properties: ```csharp [ValueObject] public partial record Money(decimal Amount, string Currency); ``` ### Leaving a property out of equality Not every property is part of what the value *is*. Mark the ones that are not with `[DontCompare]`: ```csharp [ValueObject] public partial record PersonName { public PersonName(string firstName, string lastName) => (FirstName, LastName) = (firstName, lastName); public string FirstName { get; protected init; } [DontCompare] public string? MiddleNames { get; protected init; } public string LastName { get; protected init; } [DontCompare] public string FullName => string.Join(" ", FirstName, MiddleNames, LastName).Trim(); } ``` Now two names are equal when the first and last name match, regardless of middle names. The generated `GetEqualityComponents()` is the same as above: the marked properties are simply not in it. `[DontCompare]` on a computed property such as `FullName` is good practice even though it derives from compared properties: it documents the intent and keeps the equality components minimal. On a positional record, write it as `[property: DontCompare]` on the parameter. ## Validation Override `Validate()` and the result is cached in `IsValid`: ```csharp [SingleValueObject] public partial record EmailAddress { public static EmailAddress Create(string value) => new(value); protected override bool Validate() => Value.Contains('@'); } ``` ```csharp var email = EmailAddress.Create("nope"); email.IsValid; // false email.IsValidated; // true, validation has run email.EnsureValidated(); // throws InvalidValueObjectException ``` Creating an invalid value is allowed. A value object that came from a form or a request is often invalid, and the place that made it is rarely the place that should decide what to do about that. What the toolkit guarantees is that you can always ask, and that the [always-valid twin](https://dylansnel.github.io/DDDToolkit/docs/value-objects.md#the-always-valid-twin) below can only hold a value that passed. `IsValid`, `IsValidated` and `Validate` are marked `[Internal]`, so they stay out of your database tables, your JSON and your GraphQL schema. A `bool` says no without saying why. There is a second overload for that, and it is the one to reach for when a caller has to be told what went wrong: ```csharp [SingleValueObject] public partial record EmailAddress { public static EmailAddress Create(string value) => new(value); protected override void Validate(ValidationErrorBuilder errors) { if (!Value.Contains('@')) { errors.Add("An email address needs an @.", nameof(Value), "NoAtSign", Value); } } } ``` A run that adds nothing is a pass. Both overloads run, and both have to be happy: the value is valid when `Validate()` returned `true` and no failure was added. Override whichever suits the rule; most types override one. With `DDDToolkit.FluentValidation` referenced, both are generated for you and you write rules instead; see [With FluentValidation](https://dylansnel.github.io/DDDToolkit/docs/value-objects.md#with-fluentvalidation). ## The always-valid twin Every value object record gets a generated twin named `Valid`: ```csharp EmailAddress candidate = EmailAddress.Create(userInput); ValidEmailAddress confirmed = candidate.ToValid(); // throws if invalid ``` The point is to make validity visible in signatures. A method taking `ValidEmailAddress` cannot receive an unvalidated one, so the check happens once at the boundary instead of defensively everywhere: ```csharp public Task SendWelcome(ValidEmailAddress address) // no re-validation needed ``` ```mermaid flowchart LR Input["a form, a request, a file"] --> Plain["EmailAddress: may be invalid"] Plain -->|"ToValid(), throws when invalid"| Valid["ValidEmailAddress: always valid"] Plain -->|"TryToValid(out valid, out errors)"| Valid Plain -->|"TryToValid, false"| Errors["the failures, for the caller"] Valid -->|"accepted wherever an EmailAddress is"| Use["SendWelcome(ValidEmailAddress)"] Valid -->|"With(...), the copy is validated"| Valid ```
Show the code: checking once, at the boundary The endpoint turns what it was sent into the twin, or into a refusal. Everything behind it takes the twin, and never checks again: ```csharp app.MapPost("/subscribers", (SubscribeRequest body) => { if (!EmailAddress.Create(body.Email).TryToValid(out var email, out var errors)) { return Results.ValidationProblem(errors.ToErrorDictionary()); } return Results.Ok(subscribers.Add(email)); // email is a ValidEmailAddress }); public Task SendWelcome(ValidEmailAddress address) // no re-validation needed ``` See [Failure handling](https://dylansnel.github.io/DDDToolkit/docs/value-objects.md#failure-handling).
The generator writes the twin next to the value object. Every way into it validates first, so there is no way to hold a `ValidEmailAddress` that was not checked: ```csharp title="EmailAddress.g.cs, shortened" public partial record ValidEmailAddress : EmailAddress, IAlwaysValid { public ValidEmailAddress(EmailAddress value) : base(value) { value.EnsureValidated(); _isValid = true; } public ValidEmailAddress(string value) : base(value) { EnsureValidated(); } // equality, the same as on EmailAddress } ``` The twin is made with the record's copy constructor, so it holds everything the original held: get-only properties, `protected` ones, private fields and `[Internal]` state alike. The twin derives from the original, so it is accepted anywhere the original is expected. It implements `IAlwaysValid`, and both JSON integrations refuse to deserialize it directly: deserializing straight into a twin would let invalid data enter through a type that promises the opposite. Deserialize the base type and call `ToValid()`. For the same reason, [`With`](https://dylansnel.github.io/DDDToolkit/docs/value-objects.md#changing-a-value-with) on a twin validates the copy and throws when it is invalid, rather than handing back a twin that does not keep its promise. A twin is never equal to a plain value, even one with the same components, and that holds from either side: ```csharp Money plain = new(8m, "EUR"); ValidMoney twin = plain.ToValid(); plain == twin // false twin == plain // false twin == new Money(8m, "EUR").ToValid() // true: twin to twin compares the components ``` Equality between a twin and a plain value could not be `true` in both directions. The twin is a derived record, and the compiler writes the `Equals(Money?)` that answers for it, which only accepts a `ValidMoney`. The compiler does not allow that member to be replaced. So the generated equality also compares the runtime type, the way the compiler's own record equality does, and the answer is the same whichever side is asked. To compare a twin with a plain value, compare twin to twin (call `ToValid()` on the other side) or compare the components. Hash codes do not include the type, so a twin and a plain value with the same components hash alike; that is allowed, and harmless. > [!NOTE] > **Why the twin rules out `sealed` and structs.** `ValidEmailAddress` derives from `EmailAddress`, > and three things rest on that. A twin is accepted anywhere the original is, so a signature can ask > for `ValidEmailAddress` while the rest of the code carries on with `EmailAddress`. The twin is built > by the record's copy constructor, which the compiler makes `protected` on a record that is not > sealed and `private` on one that is. And `With` is virtual, so the twin's override validates a copy > even when the twin is held as its base type. > > A sealed record cannot be derived from, which is [DDD00013](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00013), and neither > can a struct, which is why a value object has to be a reference record > ([DDD00001](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00001)). A twin that wrapped the value instead of deriving from it > would lose all three, and around a struct it could not even keep its promise: `default` and every > element of a new array skip the constructor, and the check with it. The same goes for > [struct identifiers](https://dylansnel.github.io/DDDToolkit/docs/identifiers.md#struct-or-record), which have no twin. ## Failure handling `ToValid()` throws. That is right when an invalid value is a bug, and wrong when it is an ordinary answer. An endpoint validating a request body should reply with a refusal, not a 500, and a validation pass over a whole request wants every failure at once rather than the first one to throw. So there is a second way in, and it never throws. ### TryToValid ```csharp var candidate = EmailAddress.Create(userInput); if (candidate.TryToValid(out ValidEmailAddress? email, out var errors)) { // email is not null here } else { // errors says why, in full } ``` There is a shorter overload, `TryToValid(out var email)`, for a caller that only needs to know whether it worked. And there is `TryValidate(out var errors)` for a value object you do not intend to convert, such as one nested inside another. Both work the same for `[ValueObject]`, `[SingleValueObject]` and `[EntityId] partial record`. Struct identifiers have neither, and neither do they need it: they are well formed by construction and have no twin to convert to. They are extension methods, not generated members: `TryToValid` on the generated `IValidatable` interface, so the twin type is inferred at the call site, and `TryValidate` on `ValueObject`, since it converts nothing and needs no twin. Nothing new lands on your types, so nothing new turns up in an Entity Framework model or a GraphQL schema. ### What a failure looks like Failures are `ValidationError`, which is the toolkit's own shape and needs no FluentValidation: | Member | Meaning | | --- | --- | | `Message` | What is wrong, in words you could show a user. | | `PropertyName` | The property it belongs to, or `null` for the whole value. | | `Code` | A stable code to branch on, so you never match on text. | | `AttemptedValue` | The value that was rejected, when the rule reported one. | | `Arguments` | The values the message was built from, by name, such as `MaxLength`. Never `null`. | The failures are the ones your `Validate(ValidationErrorBuilder)` added. A `Validate()` that returns `false` without describing itself produces one failure carrying `ValidationError.UnspecifiedCode`, so a caller is never handed an empty list that reads like a pass. With FluentValidation referenced you get one of these per `ValidationFailure` instead; see [With FluentValidation](https://dylansnel.github.io/DDDToolkit/docs/value-objects.md#with-fluentvalidation). `ValidationErrors` on the value object itself holds the same list. Reading it runs the rules if nothing has run them yet, exactly as reading `IsValid` does. ### Returning a refusal from an endpoint ```csharp app.MapPost("/subscribers", (SubscribeRequest body) => { if (!EmailAddress.Create(body.Email).TryToValid(out var email, out var errors)) { return Results.ValidationProblem(errors.ToErrorDictionary()); } return Results.Ok(subscribers.Add(email)); }); ``` `ToErrorDictionary()` groups the failures by property name and keeps the messages, which is the shape `Results.ValidationProblem` and `ModelStateDictionary` expect. The client gets a 400 it can read field by field, and nothing was thrown. To answer with every failure in the request rather than the first, collect them. `Prefixed()` puts a value object's failures back under the field they came from, because `Value` and `Street` say nothing about where they sat in the body: ```csharp var errors = new List(); if (!EmailAddress.Create(body.Email).TryToValid(out var email, out var emailErrors)) { errors.AddRange(emailErrors.Prefixed("email")); // "Value" becomes "email.Value" } if (!new Address(body.Street, body.City).TryToValid(out var shipTo, out var addressErrors)) { errors.AddRange(addressErrors.Prefixed("shipTo")); // "Street" becomes "shipTo.Street" } if (errors.Count > 0) { return Results.ValidationProblem(errors.ToErrorDictionary()); } return Results.Ok(subscribers.Add(email!, shipTo!)); ``` The two `!` are the one wart. The compiler knows `email` is not null inside the `if`, but not that it is set by the time you reach the last line. ### Which one to reach for | Situation | Use | | --- | --- | | The value came from outside and may be junk | `TryToValid` | | You are collecting failures across a request | `TryToValid` plus `Prefixed` | | The value was already validated at the boundary | take `ValidEmailAddress` in the signature | | An invalid value here would be a bug | `ToValid()` and let it throw | `InvalidValueObjectException` now carries `Errors` and `ObjectType`, so the throwing path says why as well. That is for the log line, not for control flow. ### Why not a Result type The obvious move would be to ship a `Result`. The toolkit deliberately does not. Teams already use FluentResults, ErrorOr, OneOf or something of their own, and a result type is infectious: once the toolkit returns one, it turns up in every signature that touches a value object, and a codebase with one result type ends up with two. What the toolkit ships instead is the Try pattern. It hands you the value and the failures, and you put them in whatever you already use. If that is a `Result`, wrapping the call takes one line. ```csharp public static Result ToResult(this EmailAddress email) => email.TryToValid(out var valid, out var errors) ? Result.Ok(valid) : Result.Fail(errors.Select(e => e.Message)); ``` ### What this does not do - There is no `Result`, on purpose. See above. - There is no async validation. `Validate` is synchronous, so a rule cannot call a database. - There are no severities. A failure is a failure; there are no warnings. - `Message` is whatever your rule wrote. To show it in the reader's language, branch on `Code`, or let [`DDDToolkit.Localization`](https://dylansnel.github.io/DDDToolkit/docs/localization.md) phrase it from `Code` and `Arguments`. - Nothing validates across value objects. A rule sees one value object, never the request around it. That is what a containing validator is for, and [`MustBeValid()`](https://dylansnel.github.io/DDDToolkit/docs/fluent-validation.md#a-value-object-in-a-request-validator) folds a value object into one. ## Changing a value: `With` A value object is never changed in place. To get a different value, you make a copy with some properties replaced. Every `[ValueObject]` has a generated `With(...)` for that: name the properties to replace and leave the rest out. For a `Money` that carries an optional note: ```csharp [ValueObject] public partial record Money(decimal Amount, string Currency, [property: DontCompare] string? Note = null); ``` ```csharp var converted = money.With(amount: 12.50m, currency: "USD"); var cleared = money.With(note: null); // null is a value too; leaving note out keeps it ``` On `Money`, `With` hands back a copy that is judged afresh, like any new value; ask `IsValid` or call `ToValid()`. On `ValidMoney`, the copy is validated before anyone can hold on to it, and an invalid copy throws `InvalidValueObjectException` on the line that asked for it: ```csharp ValidMoney valid = money.ToValid(); valid.With(amount: 5); // a ValidMoney valid.With(amount: -1); // throws here ``` What the generator writes for the two-property `Money(decimal Amount, string Currency)` and its twin: ```csharp title="Money.g.cs, shortened" partial record Money { [Internal] [HotChocolate.GraphQLIgnoreAttribute] public virtual Money With(Optional amount = default, Optional currency = default) => this with { Amount = amount.Or(Amount), Currency = currency.Or(Currency) }; } public partial record ValidMoney : Money, IAlwaysValid { [Internal] [HotChocolate.GraphQLIgnoreAttribute] public override ValidMoney With(Optional amount = default, Optional currency = default) => new(base.With(amount, currency)); } ``` A method runs after all the new values are in place, which is the one thing C#'s own `with` expression cannot offer; [below](https://dylansnel.github.io/DDDToolkit/docs/value-objects.md#why-with-is-closed-to-callers) is why that matters. It is also what makes value objects easy to test, where copying a valid value and changing one property is the natural way to build a case. The base method makes the copy and the twin's constructor validates it. That is the same constructor `ToValid()` uses. `With` is virtual, so code that only knows about `Money` still ends up in the twin's override when it is handed a twin: ```csharp static Money Discount(Money money) => money.With(amount: money.Amount - 100); Discount(valid); // throws: the discounted twin would not be valid ``` A few details: - The parameters are `Optional`, a small struct in `DDDToolkit.BaseTypes` that tells "not given" apart from "given as `null`". An ordinary optional parameter cannot, and a nullable property needs both. A value converts to it implicitly, so you never write the type. - `With` covers the properties that have a setter and are not `[Internal]`. Computed properties are not parameters. - `With` is marked `[Internal]`, and `[GraphQLIgnore]` when HotChocolate is referenced. HotChocolate otherwise publishes public methods as fields, and it cannot turn `Optional` into an input type, so the whole schema would fail to build. - If the value object declares a member named `With` itself, nothing is generated, so an existing method keeps working. ### Why `with` is closed to callers A record comes with `with`, which copies a value and changes some of its properties: ```csharp var moved = address with { City = "Utrecht" }; ``` For a value object that is exactly the operation you want. It is also a way to create a value that never went past anything that checks it. That is why properties are `protected init`: `with` only compiles inside the value object and its twin, and everybody else uses `With(...)`. This section explains why neither leaving `with` open nor adding a check to it works. You do not need it to use the toolkit; it is here for the reader who wonders why the obvious design was not chosen. #### What `with` does, step by step `money with { Amount = -1 }` compiles to three steps: 1. **Clone.** The record's hidden, compiler-generated clone method is called. It is virtual, so the copy has the *runtime* type of `money`, whatever the type of the variable. 2. **Copy constructor.** The clone method runs the copy constructor, which copies every field from the original. 3. **Init accessors.** Only now are the new values assigned, one property at a time, in the order they are written between the braces. Nothing of yours runs after step 3. C# has no hook for "the `with` expression is done", so no code can look at the finished copy before the caller gets it. #### On a plain value object this is harmless The copy constructor of `ValueObject` clears the cached verdict. A copy made by `with` has never been judged, and the first time something reads `IsValid` or `ValidationErrors`, or calls `ToValid()`, the rules run on its new contents: ```csharp var copy = money with { Amount = -1 }; // inside the type, where it is allowed copy.IsValid; // false: judged afresh ``` A `Money` is allowed to be invalid; that is what `IsValid` is for. So `with` on the plain type does not lie to anyone. #### On the always-valid twin it breaks the one promise the twin makes A `ValidMoney` exists to say "this was checked" in a signature, so the code that receives it does not check again. `with` on a twin produces another twin, holding whatever values were put in: ```csharp ValidMoney valid = money.ToValid(); var copy = valid with { Amount = -1 }; // a ValidMoney and IAlwaysValid, with Amount -1 ``` This does not only happen when someone writes `with` on a `ValidMoney` on purpose. Step 1 above copies the runtime type, so ordinary code that knows nothing about twins does it too: ```csharp static Money Discount(Money money) => money with { Amount = money.Amount - 100 }; var result = Discount(valid); // a ValidMoney, holding -90 ``` Passing a twin where a `Money` is expected is normal, because it *is* a `Money`. The code in `Discount` is reasonable, and yet `result is ValidMoney` is true for a value that is not valid. `result.IsValid` does say `false`, since the verdict was cleared, but code that trusts the type never asks. #### Why the copy cannot be checked at the `with` - **In the copy constructor:** too early. It runs in step 2, before the new values arrive, so it sees the old, valid values. - **In each init accessor:** these see half-changed objects. `with { Amount = 5, Currency = "USD" }` sets `Amount` while `Currency` still holds the old value, and a rule about the pair, such as "this amount is allowed in this currency", would fail on a combination nobody asked for. - **Throwing from the twin's copy constructor, always:** that makes every `with` on a twin fail, including `Discount` above when its result is perfectly valid. - **Checking later, when a property of the copy is first read:** possible, but the exception is then thrown wherever the copy happens to be used, which can be far from the line that made it. The stack trace points at the reader, not at the cause. - **Protecting only the twin**, by declaring its properties again as `protected init`: that stops `valid with { ... }`, but not `Discount`, which goes through `Money`'s own properties. None of these is acceptable for a type whose job is to be trustworthy, so the toolkit makes the situation impossible instead. With `protected init`, `with` only compiles inside the value object and its twin: ```csharp money with { Amount = -1 }; // outside the type: CS0272, the init accessor is inaccessible ``` The generated code of the value object and its twin still uses `with`. That code is written to check what it produces, and `With(...)` is how everybody else gets the same operation with the check included. ## Positional records A positional record turns each parameter into a property, and that property is always `public init`. C# has no syntax to ask for anything else. Left as it is, it would open `with` to every caller again. C# has one way around it. When a property with a parameter's name is declared anywhere in the record, in any of its partial parts, the compiler does not synthesize one for that parameter. The generator uses that: it declares each positional property itself, as `protected init`, with an initializer that reads the parameter. For `record Money(decimal Amount, string Currency)`: ```csharp title="Money.g.cs, shortened" partial record Money : ValueObject, IValidatable { [JsonInclude] public decimal Amount { get; protected init; } = Amount; [JsonInclude] public string Currency { get; protected init; } = Currency; // equality over Amount and Currency [JsonConstructor] protected Money() : this(default(decimal)!, default(string)!) { } // ToValid() and With(...) } ``` `= Amount` reads the constructor parameter, not the property, so the primary constructor still fills the properties. The parameterless constructor chains to the primary one, because in a positional record every other constructor has to. `[JsonInclude]` is there because System.Text.Json cannot reach a `protected init` on its own: without it the value would deserialize as its defaults, and a value object in a domain event would come out of the outbox empty. It is left out when the project does not reference System.Text.Json, and not repeated when the parameter already carries it. | What the positional record gives you | After generation | |---|---| | The constructor, `new Money(10m, "EUR")` | unchanged | | Deconstruction, `var (amount, currency) = money;` | unchanged | | Value equality | the toolkit's, over the properties not marked `[DontCompare]` | | `money with { Amount = 1 }` outside the type | does not compile (CS0272) | | `money.With(amount: 1)` | generated, see [`With`](https://dylansnel.github.io/DDDToolkit/docs/value-objects.md#changing-a-value-with) | Attributes aimed at the property, such as `[property: DontCompare]`, only work while the compiler synthesizes the property. The generator copies them onto the property it declares, so they still apply. The compiler does not know that and warns that the attribute is ignored (CS0657). A suppressor in the toolkit removes that warning, but only when the attribute really did arrive on the property. A property you declare yourself takes the place of the synthesized one, as it always does in a record, and the generator leaves it alone. Use that when a parameter needs something the generated declaration does not do: ```csharp [ValueObject] public partial record Money(decimal Amount, string Currency) { public string Currency { get; protected init; } = Currency.ToUpperInvariant(); } ``` Such a property is checked like any other declared one, so it needs `protected init` too, and a `[property: ...]` attribute on its parameter is lost; the CS0657 warning then stays to tell you. ## Hiding members `[Internal]` marks a member as infrastructure. It is excluded from equality, from Entity Framework mapping, from the Newtonsoft contract resolver and from the GraphQL schema. ```csharp [Internal] public string CacheKey => $"{FirstName}:{LastName}"; ``` Use it for anything an outside observer should not see. `[DontCompare]` is narrower: the member stays visible and persisted, it simply does not take part in equality. ## Entity Framework Everything above works without a database. Once you store value objects, reference `DDDToolkit.EntityFramework` and its generator adds the mapping: - `[SingleValueObject]` and identifiers get a generated `ValueConverter`, so an `EmailAddress` is stored as a plain `string` column. `AddConverters` registers them all; see [Entity Framework](https://dylansnel.github.io/DDDToolkit/docs/entity-framework.md). - `[ValueObject]` records are annotated `[ComplexType]`, so their properties are stored inline in the owning table rather than in a table of their own. A single value object can say how wide its column is. `ColumnLength` becomes `HaveMaxLength` in the generated configuration: ```csharp [SingleValueObject(ColumnLength: MaxLength)] public partial record EmailAddress { public const int MaxLength = 255; public static EmailAddress Create(string value) => new(value); } ``` The generator writes a converter into the record, for it and for its twin, and one registration per type in the project's `AddConverters`, where the length ends up: ```csharp title="EmailAddress.Converter.g.cs, shortened" partial record EmailAddress { public sealed class EmailAddressConverter : ValueConverter { public EmailAddressConverter() : base(static v => v.Value, static v => new EmailAddress(v)) { } } } ``` ```csharp title="ConverterExtensions.g.cs, shortened" modelConfigurationBuilder.Properties().HaveConversion().HaveMaxLength(255); modelConfigurationBuilder.DefaultTypeMapping().HasConversion().HasMaxLength(255); ``` `ColumnLength` has no effect on validation and none at all without the Entity Framework package. A constant such as `MaxLength` lets a validation rule use the same number. A `[ValueObject]` gets no converter. Its generated part only carries the attribute that tells Entity Framework to store its properties as columns of the owner: ```csharp title="PersonName.EntityFramework.g.cs" [ComplexType] partial record PersonName { } [ComplexType] partial record ValidPersonName { } ``` ## With FluentValidation If you write rules with FluentValidation, reference `DDDToolkit.FluentValidation` and write a value object's rules as a validator instead of a `Validate()` override: ```csharp [SingleValueObject] public partial record EmailAddress { public static EmailAddress Create(string value) => new(value); partial class Validator { public Validator() => RuleFor(x => x.Value).EmailAddress(); } } ``` The generator writes the rest, and `IsValid`, `TryToValid()` and the twin run your rules. `MustBeValid()` folds a value object into the validator you write for a request. See [FluentValidation](https://dylansnel.github.io/DDDToolkit/docs/fluent-validation.md). ## Requirements The declaration must be a `partial record` that is not sealed. A class or struct carrying `[ValueObject]` or `[SingleValueObject]` reports [DDD00001](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00001); a non-partial declaration reports [DDD00005](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00005). The properties of a `[ValueObject]` record you declare yourself must be `{ get; protected init; }`; the ones a [positional record](https://dylansnel.github.io/DDDToolkit/docs/value-objects.md#positional-records) declares through its parameters are taken care of by the generator. # Entities and aggregates An order is placed, gets another line, has its address corrected, is paid and is shipped. Every property changed, and it is still the same order. Something whose identity outlives its values is an entity, and two entities are the same when their identifiers are, whatever else they hold. Some rules are not about one entity. "A placed order has at least one line" is about an order and its lines together, and it only holds if nobody can take a line away without the order knowing. An aggregate is that group: a root entity, the order, and the objects it owns, the lines, treated as one unit. The root owns the cluster and forms the consistency boundary around it. It is the only way in: code outside holds the order, never a line on its own, and every change goes through a method on the order, which knows the rules. That is why the aggregate is the unit of consistency. Its rules hold after every change, and it is changed as a whole or not at all. Two aggregates, such as an order and the customer who placed it, are kept apart, and each answers only for itself. The toolkit distinguishes the two, and the distinction carries real behaviour. Use `[AggregateRoot]` for the object you load, save and reference from elsewhere. Use `[Entity]` for something that only exists inside one aggregate, like an order line. This page declares both, then the collections that hold one inside the other and the references between aggregates, then events, invariants and the version. Storage comes last. ## Declaring an aggregate ```csharp [AggregateRoot] public partial class Order { public Order(OrderId id, CustomerId customer) : base(id) { Customer = customer; } public CustomerId Customer { get; private set; } public OrderStatus Status { get; private set; } = OrderStatus.Draft; } ``` `OrderId` is an identifier type, declared with `[EntityId]`; see [Identifiers](https://dylansnel.github.io/DDDToolkit/docs/identifiers.md). The class has to be `partial` so the generator can add to it. It supplies the base class and a protected parameterless constructor for Entity Framework and serializers, which is why your own constructor calls `base(id)`: ```csharp title="Order.g.cs, shortened" partial class Order : AggregateRoot { protected Order() { } partial void CheckInvariants(); // ... the invariant checks, see Invariants below } ``` The base class brings the `Id`, and for a root the `Version` and `RaiseDomainEvent` described further down. Equality comes from the base type and compares identifiers, so two instances of the same order loaded in different contexts are equal. `==`, `!=`, `Equals` and `GetHashCode` are all consistent and null-safe. ## Child entities ```csharp [Entity] public partial class OrderLine { public OrderLine(OrderLineId id, ProductId product, int quantity) : base(id) => (Product, Quantity) = (product, quantity); public ProductId Product { get; private set; } public int Quantity { get; private set; } } ``` The generator writes the same as for a root, with `Entity` as the base class: ```csharp title="OrderLine.g.cs, shortened" partial class OrderLine : Entity { protected OrderLine() { } // ... } ``` A child entity has identity and equality but no events and no version, because it is not a consistency boundary; its root is. With Entity Framework referenced, that package's generator adds a part of its own that marks it owned, so it is loaded and saved with the aggregate that owns it: ```csharp title="OrderLine.EntityFramework.g.cs" [Owned] partial class OrderLine { } ``` Side by side: | | `[Entity]` | `[AggregateRoot]` | |---|---|---| | Base type | `Entity` | `AggregateRoot` | | Identity and equality | Yes | Yes | | Domain events | No | Yes | | Concurrency version | No | Yes | | Invariants | Yes, run by a save that changes it | Yes, run by a save that changes it or anything it owns | | Entity Framework | Mapped as an owned type | Mapped as its own entity type | ## Read-only collections An order holds its lines. Exposing a `List` from the aggregate lets any caller add to it and bypass your invariants. Exposing `_items.AsReadOnly()` from a hand-written field is correct but tedious: a field, a property and a wrapper for every collection. The toolkit writes them for you. Declare the property you want, get-only and `partial`: ```csharp [AggregateRoot] public partial class Order { public partial IReadOnlyList Lines { get; } public void AddLine(OrderLine line) => _lines.Add(line); public void RemoveLine(OrderLineId id) => _lines.RemoveAll(l => l.Id == id); } ``` The generator writes the field behind it and the read-only view in front of it: ```csharp title="Order.g.cs, shortened" partial class Order : AggregateRoot { // ... private readonly List _lines = new(); private IReadOnlyList? __linesView; [BackingField(nameof(_lines))] public partial IReadOnlyList Lines => __linesView ??= _lines.AsReadOnly(); } ``` `Lines` appears twice because it is one property, not two: C# 13 partial properties split into a declaring half, which you write, and an implementing half, which the generator writes. `_lines` is usable from your half the moment you declare the property, which is why `AddLine` above compiles without you having written the field. Callers see a read-only view that cannot be cast back to `List`; the aggregate mutates through `_lines`. `[BackingField]` is for Entity Framework, and is described [below](https://dylansnel.github.io/DDDToolkit/docs/entities-and-aggregates.md#stored-with-entity-framework). ### Supported property types | Declared type | Backing field | View | |---|---|---| | `IReadOnlyList` | `List` | `AsReadOnly()` | | `IReadOnlyCollection` | `List` | `AsReadOnly()` | | `IEnumerable` | `List` | `AsReadOnly()` | | `IReadOnlySet` | `HashSet` | `ReadOnlySet` wrapper | The field name is the property name in camel case with a leading underscore: `Lines` gives `_lines`, `OrderLines` gives `_orderLines`. Accessibility and `virtual`, `override` and `sealed` are mirrored from your declaration, so a `protected partial IReadOnlyList` stays protected. Child entities may declare partial collection properties exactly like roots. ### The property must be get-only ```csharp public partial IReadOnlyList Lines { get; set; } // DDD00020 ``` A setter would let a caller replace the whole collection, which defeats the purpose. Declaring one reports [DDD00020](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00020) and the property is left unimplemented, so the build fails loudly rather than silently producing something you did not ask for. ### Stored with Entity Framework Entity Framework reads and writes the field directly thanks to `[BackingField]`, so it never tries to write through the read-only property. See [Child entities and owned collections](https://dylansnel.github.io/DDDToolkit/docs/entity-framework.md#child-entities-and-owned-collections). The `[BackingField]` annotation is only emitted when the project references Entity Framework, so the same declaration works in a domain project with no persistence dependency. ### Why the view is kept The view is held rather than rebuilt. `AsReadOnly()` is `new ReadOnlyCollection(this)` with no cache of its own, so an expression bodied property would build one wrapper per read and throw it away: 24 bytes every time somebody looks. Holding it is safe because the list field is `readonly`, so the collection the view wraps can never be swapped out from under it, and the view is a window on the list rather than a copy, so it shows everything the aggregate does afterwards. See [Performance](https://dylansnel.github.io/DDDToolkit/docs/performance.md#reading-a-read-only-collection). ## Reference other aggregates by id An aggregate owns everything inside it and nothing outside it. Another aggregate is referenced by its id: ```csharp [AggregateRoot] public partial class Order { public CustomerId Buyer { get; private set; } // an id, not a Customer public partial IReadOnlyList Lines { get; } // owned, so a reference } ``` Holding the `Customer` itself instead reports [DDD00021](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00021). ```mermaid flowchart LR subgraph aggregate ["the Order aggregate: one transaction, one Version"] direction TB Order["Order, the root"] --> Line1["OrderLine"] Order --> Line2["OrderLine"] Order --> Address(["Address, a value object"]) end Order -. "CustomerId, an id and nothing more" .-> Customer["Customer, another aggregate"] ``` Everything inside the box is loaded and saved with the order and answers to its invariants. The customer is outside it: the order knows which customer, and nothing else about it. ### Why the id is what keeps the boundary An aggregate is two boundaries at once, and a direct reference breaks both. It is a **loading boundary**. `context.Orders.Find(id)` should load an order, its lines and nothing else. The moment `Order` has a `Customer` property, Entity Framework has a navigation to follow. Either it loads the customer with every order, or it leaves a proxy that loads one later, per order, in a loop nobody wrote. An `OrderId`-shaped hole in the object graph is a decision you can see: the code that needs the customer asks for it, by id, through the customer repository. It is a **consistency boundary**. Every root carries its own `Version`, and the point of that number is that one save changes one aggregate and one version says whether anyone else changed it. With a direct reference, `order.Buyer.Rename(...)` inside an order method makes a single `SaveChanges` write two roots in one transaction. Now a conflict on the customer rolls back the order, the order's version says nothing about the customer, and the two aggregates are one aggregate wearing two names. The id also survives things a reference does not. It serialises into an event, crosses a queue, goes into a URL, and still means the same customer when the two aggregates end up in different services or different databases. A reference only works while both objects are in the same unit of work. None of this is unique to this toolkit; it is the oldest rule in the pattern. What the toolkit adds is that it already knows which of your types are aggregate roots and which are ids, so it can check. ### The one reference that is allowed A child entity may navigate back to the root that owns it: ```csharp [AggregateRoot] public partial class Order { public partial IReadOnlyList Lines { get; } } [Entity] public partial class OrderLine { public Order Order { get; private set; } = default!; // allowed } ``` This one does not widen anything. The line is loaded with the order, saved with the order and cannot outlive it, and Entity Framework uses the inverse navigation when it maps the owned type. The toolkit checks the claim rather than taking it: `Order` has to hold `OrderLine` back, in a collection or in a single property, and the reference from the child has to be single valued. A child entity holding a root that does not own it reports DDD00021 like anything else. ### When you disagree DDD00021 is a warning, and nothing about generation changes when it fires. A model that really is loaded and saved as one unit, or a legacy mapping you are not ready to unpick, can keep its reference by turning the rule off for the project: ```xml $(NoWarn);DDD00021 ``` There is no per-member escape hatch. A `#pragma warning disable` cannot suppress a source generator's diagnostic, so it is the whole project or nothing. See [DDD00021](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00021) for why, and for the full list of what does and does not report. ## Domain events Only aggregate roots raise events, because only the root is a consistency boundary. Raising is `protected`, so an event can only come from inside the aggregate that owns it: ```csharp public void Ship(TrackingCode code) { if (Status != OrderStatus.Paid) { throw new InvalidOperationException("Only a paid order can ship."); } Status = OrderStatus.Shipped; RaiseDomainEvent(new OrderShipped(Id, code)); } ``` Draining is not part of the public surface either. `IHasDomainEvents` is implemented explicitly, so the persistence layer can collect events and application code cannot quietly discard them: ```csharp IReadOnlyList pending = order.DomainEvents; // read-only view var events = ((IHasDomainEvents)order).DequeueDomainEvents(); // persistence only ``` See [Domain events](https://dylansnel.github.io/DDDToolkit/docs/domain-events.md). ## Invariants A rule about the whole aggregate, such as "a placed order has at least one line", belongs to the root. The generator declared a `CheckInvariants()` seam in its half of the class; implement it in yours: ```csharp [AggregateRoot] public partial class Order { partial void CheckInvariants() { if (Status != OrderStatus.Draft && Lines.Count == 0) { throw InvariantViolation("A placed order must have at least one line."); } } } ``` A rule that deserves a name, a code a caller can branch on, or a test of its own is better written as a nested `IInvariant` instead. The entity runs both. Once the aggregate is stored with Entity Framework, every save that writes it runs them, and a broken rule stops the save. An aggregate that states nothing costs nothing: the compiler erases an unimplemented `partial void` and every call to it. The same question can be asked without a database, and asking the root asks the whole aggregate: ```csharp order.AddLine(line); var broken = order.GetInvariantViolations(); // the order's rules, and every line's order.EnsureInvariants(); // the same, and throws instead of answering ``` See [Invariants](https://dylansnel.github.io/DDDToolkit/docs/invariants.md) for the two stages, what one question covers and when the self-only pair is the one you want, for when to reach for each shape of rule, for the interceptor order, and for why an invariant across two aggregates is a design question rather than a missing feature. ## Declaring the identifier with it `OrderId` above is a type you declared with `[EntityId]`. When the identifier is only ever used to identify this one aggregate, you can skip that declaration and name the raw value instead: ```csharp [AggregateRoot("ORD")] public partial class Order { } // also generates OrderId ``` The toolkit then generates `OrderId` as well, as a `readonly partial record struct` with everything an explicitly declared identifier gets. The name is the entity's name with `Id` appended, and the first argument is the optional prefix. `[Entity]` does the same for a child entity. Keep the separate `[EntityId]` declaration for an identifier that other aggregates, DTOs or API contracts refer to; a type other people read deserves a declaration they can find. See [Identifiers](https://dylansnel.github.io/DDDToolkit/docs/identifiers.md#letting-the-entity-declare-the-id). ## Optimistic concurrency Every aggregate root carries a version, from its base class: ```csharp public long Version { get; private set; } ``` It is 0 for a new aggregate. The Entity Framework integration increments it on every save that touches the aggregate, including saves that only changed something it owns, and refuses a save that carries a stale one: two users editing the same order produce a `ConcurrencyConflictException` for the second one, naming the aggregate type and id. The version is what makes an aggregate a unit of consistency. Without it two concurrent saves are last-write-wins, silently. It says nobody else changed the aggregate; it says nothing about whether the aggregate is *consistent*, which is what the [invariants](https://dylansnel.github.io/DDDToolkit/docs/entities-and-aggregates.md#invariants) are for. See [Optimistic concurrency](https://dylansnel.github.io/DDDToolkit/docs/entity-framework.md#optimistic-concurrency) for how the version is mapped and checked, what counts as touching the aggregate, and what to do when the exception arrives. ## Designing aggregates Declaring an aggregate is one attribute. Deciding what belongs inside it is the hard part, and it is a question about your rules and your write patterns that no compiler can see. Vaughn Vernon's four rules of aggregate design are the best short guide to it. [Designing aggregates](https://dylansnel.github.io/DDDToolkit/docs/aggregate-design.md) says why each rule exists, what the toolkit does for it, and, for the rule it cannot help with, what to ask yourself instead. ## Persistence Nothing above needs a database. With `DDDToolkit.EntityFramework` referenced, the same declarations are mapped with no configuration: a root as an entity type, a child entity as an owned type, a read-only collection through its backing field, and `Version` as a concurrency token. See [Entity Framework](https://dylansnel.github.io/DDDToolkit/docs/entity-framework.md). What the toolkit deliberately does not add is the bookkeeping many persistence layers put on every row. ### Auditing and soft delete The toolkit has no `CreatedAt`, `CreatedBy`, `UpdatedAt`, `UpdatedBy`, `IAuditable`, `IsDeleted` or `ISoftDeletable`, and it is not going to grow them. `Version` is the only bookkeeping field an aggregate root gets, and it is there because optimistic concurrency is a correctness property of the consistency boundary, not a reporting feature. This is a deliberate position, not an omission. "Who last touched this row" is a question about your rows and your users, and the answer belongs to your persistence layer. Putting it on the domain type means every aggregate in the system carries four properties its behaviour never reads, your constructors take a user, and your unit tests need a logged-in principal to build an order. A base class in your own solution can do it in twenty lines and can say what your organisation actually means by "modified", which no library can guess. Nothing in this section is generated, and the toolkit adds no convention or interceptor for it. What follows is plain Entity Framework, in your own context. So decide which of these you are actually asking for. **It is domain data.** "Who approved this order", "when was it cancelled" are facts the business talks about and rules depend on. Model them as ordinary properties, set by the method that does the thing: ```csharp public void Approve(EmployeeId approver, DateTimeOffset at) { Approver = approver; ApprovedAt = at; RaiseDomainEvent(new OrderApproved(Id, approver, at)); } ``` That is not auditing. It is the domain, and it is testable without a database. **It is row bookkeeping.** Nothing in the domain reads it; you want it for support and forensics. Keep it out of the domain type entirely and use Entity Framework shadow properties, so the columns exist and the C# class does not: ```csharp protected override void OnModelCreating(ModelBuilder modelBuilder) { foreach (var entityType in modelBuilder.Model.GetEntityTypes() .Where(type => typeof(IAggregateRoot).IsAssignableFrom(type.ClrType))) { modelBuilder.Entity(entityType.ClrType).Property("CreatedAt"); modelBuilder.Entity(entityType.ClrType).Property("CreatedBy"); modelBuilder.Entity(entityType.ClrType).Property("UpdatedAt"); modelBuilder.Entity(entityType.ClrType).Property("UpdatedBy"); } } ``` Fill them from an interceptor of your own, registered beside the toolkit's. Who is calling is the toolkit's `ICallerAccessor`, the same one [row level security](https://dylansnel.github.io/DDDToolkit/docs/row-level-security.md#running-queries-as-the-caller) asks, so the column names whoever the database thought was calling: ```csharp public sealed class AuditInterceptor(ICallerAccessor callers, TimeProvider clock) : SaveChangesInterceptor { public override ValueTask> SavingChangesAsync( DbContextEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) { // Caller.System for background work, which has no user: the columns stay null. var caller = callers.Current; foreach (var entry in eventData.Context!.ChangeTracker.Entries()) { if (entry.Metadata.FindProperty("CreatedAt") is null) { continue; // not an audited type } if (entry.State == EntityState.Added) { entry.Property("CreatedAt").CurrentValue = clock.GetUtcNow(); entry.Property("CreatedBy").CurrentValue = caller.UserId; } else if (entry.State == EntityState.Modified) { entry.Property("UpdatedAt").CurrentValue = clock.GetUtcNow(); entry.Property("UpdatedBy").CurrentValue = caller.UserId; } } return base.SavingChangesAsync(eventData, result, cancellationToken); } } ``` ```csharp db.UseDDDToolkit(serviceProvider) .AddInterceptors(serviceProvider.GetRequiredService()); ``` **On Postgres, let the database fill them.** With [row level security](https://dylansnel.github.io/DDDToolkit/docs/row-level-security.md) on, every connection a context opens already carries the caller, so a default and a trigger can fill the columns instead of the interceptor. Then they are right for every writer, the application, PostgREST and supabase-js alike, and none of them can forge them. The trigger goes in a migration of your own: ```sql create function ordering.touch_row() returns trigger language plpgsql as $$ begin new."UpdatedAt" := now(); new."UpdatedBy" := ddd.caller_id(); -- auth.uid() on Supabase new."CreatedAt" := old."CreatedAt"; -- where a row came from is never rewritten new."CreatedBy" := old."CreatedBy"; return new; end $$; create trigger touch_row before update on ordering."Orders" for each row execute function ordering.touch_row(); ``` and the model says the database fills them, so Entity Framework neither sends them nor keeps a stale value after a save: ```csharp modelBuilder.Entity(order => { order.Property("CreatedAt").HasDefaultValueSql("now()"); order.Property("CreatedBy").HasDefaultValueSql("ddd.caller_id()"); order.Property("UpdatedAt").ValueGeneratedOnAddOrUpdate(); order.Property("UpdatedBy").ValueGeneratedOnAddOrUpdate(); }); ``` Read a shadow property back with `context.Entry(order).Property("CreatedAt")`, or query it with `EF.Property(order, "CreatedAt")`. If you would rather have real properties, declare them on each aggregate and give them an interface of your own, such as `IAudited`, so the interceptor has one type to look for. A base class of your own does not work: the generator writes the base class of every `[AggregateRoot]` and `[Entity]` itself, so a class that also names a base fails to compile with CS0263 ("partial declarations must not specify different base classes"). **It is a history of what happened.** Then you already have it. The domain events an aggregate raises are a record of every meaningful change, written by the aggregate that knows what the change meant. Turn on the [outbox](https://dylansnel.github.io/DDDToolkit/docs/event-delivery.md#the-outbox) and keep the rows instead of deleting them, or write your own handler that appends them to an event table. An audit trail assembled from `UpdatedBy` columns tells you a row changed; a trail of `OrderCancelled` tells you what happened, and why. An audit trail people read, who did what and when across the whole application, is a supporting domain of its own rather than something every module carries: an Audit module that subscribes to the other modules' [integration events](https://dylansnel.github.io/DDDToolkit/docs/integration-events.md) and keeps its own history, in its own schema, answering its own questions. The modules it audits do nothing for it beyond raising the events they already raise; an event the trail needs to attribute says who acted, as a field of its contract. **Soft delete** is the same answer. Entity Framework does it with a flag and a global query filter, and neither needs the toolkit: ```csharp modelBuilder.Entity().HasQueryFilter(order => !EF.Property(order, "IsDeleted")); ``` Two things to know before you reach for it. A filtered row still occupies its unique indexes, so a "deleted" customer still owns their email address. And query filters do not apply to raw SQL or to anything outside Entity Framework, so the flag is a convention your reporting jobs have to know about. Often the honest model is a domain state, `OrderStatus.Cancelled`, which the rest of the domain can reason about, rather than a row that pretends not to exist. ## Requirements The declaration must be a `partial class`. A record or struct carrying `[Entity]` or `[AggregateRoot]` reports [DDD00002](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00002); a non-partial class reports [DDD00005](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00005). The type argument is either an identifier, which is any type carrying `[EntityId]` or implementing `IEntityId`, or the raw value an identifier should wrap, which must be a value type or a `string`. Anything else reports [DDD00008](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00008). When the toolkit generates the identifier and something else already holds the name it would take, that reports [DDD00007](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00007). A field or property typed as another aggregate root reports [DDD00021](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00021). That one is a warning: the code compiles and everything is still generated. # Invariants An invariant is a rule that must be true of a whole aggregate every time anyone can look at it. Not "this field is required" and not "this input looks wrong", but "an order with no lines is not an order", "a tab may never total more than its limit", "exactly one address on a customer is the billing address". Vernon's first rule of aggregate design is to model true invariants within one consistency boundary, and this is the place the toolkit gives you to write them down. Written as an `if` in the handler that changes the aggregate, such a rule holds for as long as every handler remembers it. The next handler that removes a line without checking stores an order the domain says cannot exist, and nothing notices until somebody reads the row. A rule stated on the aggregate is asked of the aggregate, whichever method changed it. The toolkit does not define any invariants for you. It cannot: yours are about your domain. What it supplies is somewhere to write them, two moments at which they are asked, and the guarantee that the second of those moments is never skipped. This page starts by writing a rule, then says how and when it is asked and how much one question covers. It ends with where the rules stop, and with the reasoning behind the design. ## Stating a rule There are two shapes, and the difference between them is not a style preference. ### The seam Every `[Entity]` and `[AggregateRoot]` gets a generated partial method. Implement it in your own part of the class: ```csharp [AggregateRoot] public partial class Order { public partial IReadOnlyList Lines { get; } public OrderStatus Status { get; private set; } partial void CheckInvariants() { if (Status != OrderStatus.Draft && Lines.Count == 0) { throw InvariantViolation("A placed order must have at least one line."); } } } ``` Write it exactly like that: `partial void`, no accessibility modifier, in a file of your own. The generator writes the other half. `InvariantViolation(...)` is a protected helper on `Entity`. It builds an `InvariantViolationException` that already knows this aggregate's type and id, so the message names the thing that is broken. There is an overload taking several messages, so one check can report everything it found: ```csharp partial void CheckInvariants() { var problems = new List(); if (Lines.Count == 0) problems.Add("An order must have at least one line."); if (Total > CreditLimit) problems.Add($"An order may not exceed the credit limit of {CreditLimit}."); if (problems.Count > 0) throw InvariantViolation(problems); } ``` The seam reports by throwing, but an aggregate can also be asked without throwing, which is the first of the [two stages](https://dylansnel.github.io/DDDToolkit/docs/invariants.md#the-two-stages) below. Asked, it catches whatever the seam threw, unpacks it, and reports violations carrying `InvariantViolation.SeamCode`, which is the literal string `"CheckInvariants"`. The seam has nowhere to put a code of its own, so it gets one that says where it came from. ### A named invariant The other shape is one rule, one type, implementing `IInvariant`, nested inside the entity it is about: ```csharp // Ordering/Invariants/MustHaveLines.cs public partial class Order { public sealed class MustHaveLines : IInvariant { /// What a caller branches on, so nobody has to match on the message. public const string ViolationCode = "ORDER_HAS_NO_LINES"; public string Code => ViolationCode; public InvariantFailure? Check(Order order) => order.Status != OrderStatus.Draft && order._lines.Count == 0 ? "A placed order must have at least one line." : null; } } ``` `Check` returns `null` when the rule holds, and otherwise what is wrong in the domain's own words. It returns an `InvariantFailure`, and a string converts to one, so a rule with nothing more to say returns its message. Not a violation object so that holding is free: this runs for every changed entity on every save, and the consistent path returns `null` and allocates nothing. The generated code pairs the failure with `Code`, so the code is written down once. A message that names values should hand them on as well, so it can be [phrased in another language](https://dylansnel.github.io/DDDToolkit/docs/localization.md) without the number already baked into the sentence: ```csharp public InvariantFailure? Check(Order order) => order.Total <= order.CreditLimit ? null : new InvariantFailure($"An order may total at most {order.CreditLimit}.") .With("CreditLimit", order.CreditLimit); ``` They arrive on the violation as `InvariantViolation.Arguments`. The generator finds these, builds one instance of each in a static array, and runs them before the seam. Both stages run the same array. For the `Order` above, with `MustHaveLines` and a seam, it writes the seam's declaration, the array, and the one routine that runs them: ```csharp title="Order.g.cs, shortened" partial class Order : AggregateRoot { // ... partial void CheckInvariants(); private static readonly IInvariant[] __invariants = [ new Shop.Order.MustHaveLines(), ]; private void CollectInvariantViolations( ref List? violations, out InvariantViolationException? seamFailure) { foreach (var invariant in __invariants) { // Null means the rule holds, and a rule that holds allocates nothing. var failure = invariant.Check(this); if (failure is not null) { violations ??= new List(); violations.Add(new InvariantViolation(invariant.Code, failure.Message, typeof(Shop.Order), Id) { Arguments = failure.Arguments }); } } seamFailure = null; try { CheckInvariants(); } catch (InvariantViolationException failure) { // ... kept as seamFailure, and each message it carries becomes a violation with InvariantViolation.SeamCode } } ``` Nothing is looked up when the application runs: the rules are named in the array, and the list of violations is created on the first failure, so a consistent order allocates nothing here. ### Which one to reach for The seam is less ceremony, and for a small rule that is the whole argument. One `if` inside the class it is about, no extra type, no extra file, no code to invent, no name to agree on. An aggregate with a single rule that fits on one line is not better off with a nested class, and a codebase that insists on one is paying for structure it does not use. Reach for the seam by default. An invariant object earns its keep when one of these is true: - **The rule deserves a name.** `MustShipSomewhereWeDeliver` says in the file tree what a condition buried in an `if` says only to whoever reads the body. - **A caller needs to branch on which rule broke.** This is the big one. Violations from the seam all carry `SeamCode`, so an application that wants to answer differently for "no lines" than for "over the credit limit" cannot tell them apart without matching on the message, and a message is not an API. A named rule has a code, and the nested type gives that code a home: `Order.MustHaveLines.ViolationCode`. - **The rule deserves a test of its own.** `new Order.MustHaveLines().Check(order)` is a unit test with no aggregate mutation, no exception to catch and no database. The seam can only be tested through the entity. - **There are several rules and they keep arriving.** A seam with six `if` statements and a hand-built list of problems has become a file of its own trying to get out. Six rules report themselves without any of them knowing about the others. An entity can have both, and mixing them is normal: the rules that earned a name get one, and the one-liner that never will stays in the seam. That is what [`Examples/ModularMonolith.Supabase`](https://github.com/DylanSnel/DDDToolkit/tree/main/Examples/ModularMonolith.Supabase) does, with a named rule on the order, a named rule on the line, and one seam left where it belongs. If you want to find the rules that should be promoted, branch on `InvariantViolation.SeamCode`: every violation carrying it came from a seam and has no code a caller can use. ## The two stages A broken invariant means two different things depending on when you ask. **Before the save, the application is asking a question.** A user added a line, a handler took the last item out of a basket, a command half-built an aggregate it intends to finish later. "Not consistent yet" is a real answer here, and the caller wants to do something with it: show it, log it, put it in a response. `GetInvariantViolations()` answers that question and never throws. An empty list means consistent. **At the save, nobody is asking.** The transaction is the consistency boundary, and an aggregate that is about to be written broken has already gone wrong somewhere upstream. `EnsureInvariants()` throws `InvariantViolationException`, the save stops, and nothing reaches the database. ```mermaid flowchart TD Act["a handler acts on the order"] --> Ask{"order.GetInvariantViolations()"} Ask -->|"violations"| Refuse["answer 422, naming each rule and the entity that broke it"] Ask -->|"none"| Save["SaveChangesAsync()"] Save --> Check{"the save asks every added or changed entity: EnsureOwnInvariants()"} Check -->|"all hold"| Written["written"] Check -->|"one is broken"| Throw["InvariantViolationException, and nothing is written"] ```
Show the code: a rule, a handler that asks, and the save that checks A rule is a class nested in the entity it is about. The generator finds it; there is nothing to register: ```csharp public partial class Order { public sealed class MustHaveLines : IInvariant { public string Code => "ORDER_HAS_NO_LINES"; public InvariantFailure? Check(Order order) => order.Lines.Count == 0 ? "An order must have at least one line." : null; } } ``` A handler that wants to answer rather than fail asks before it saves: ```csharp order.AddLine(command.Sku, command.Quantity); // the domain acts var violations = order.GetInvariantViolations(); // and answers for its lines as well if (violations.Count > 0) { return Results.UnprocessableEntity(violations.Select(v => new { v.Code, v.Message })); } await context.SaveChangesAsync(cancellationToken); // still guarded, by the interceptor ``` The save checks because `UseDDDToolkit` puts the interceptor on the context: ```csharp builder.Services.AddDbContext((services, options) => options .UseNpgsql(connectionString) .UseDDDToolkit(services)); ``` See [A handler asks before it saves](https://dylansnel.github.io/DDDToolkit/docs/invariants.md#a-handler-asks-before-it-saves) and [At the save](https://dylansnel.github.io/DDDToolkit/docs/invariants.md#at-the-save).
```csharp var broken = order.GetInvariantViolations(); // stage 1: what is wrong, if anything order.EnsureInvariants(); // stage 2: nothing is wrong, or nothing is written ``` | | `GetInvariantViolations()` | `EnsureInvariants()` | |---|---|---| | Asks | "Is this acceptable yet?" | "Prove you may be stored." | | Answers with | `IReadOnlyList` | nothing, or an exception | | Consistent | an empty list | returns | | Broken | the violations, each with its code | `InvariantViolationException` | | Called by | your application, when it wants to know | the save, before every write ([At the save](https://dylansnel.github.io/DDDToolkit/docs/invariants.md#at-the-save)) | | Both answer for | the whole aggregate, children included | the same | That last row is the one people are surprised by, so it gets a section of its own below. ### Why one of them throws Because of who is holding it. The save-time call happens in the middle of a save, which has nowhere to put a list: there is no caller in the middle waiting to read one, and a return value nobody is positioned to read is a return value that gets dropped. An exception is the only answer that cannot be ignored by accident, and being unable to ignore it by accident is the entire guarantee. Turning it into a return value would make the strongest promise in the toolkit depend on every future caller remembering to check. And because of what has already happened. By the save, the aggregate has been mutated by its own methods and the state is a fact, not a proposal. There is no question left to answer. What the two stages do *not* do is disagree. The generated code runs both through one routine that collects violations, and the two differ only in how they end: one returns the list, the other throws once with every message in it: ```csharp title="Order.g.cs, shortened" public override IReadOnlyList GetInvariantViolations() { List? violations = null; CollectInvariantViolations(ref violations, out _); CollectChildInvariantViolations(ref violations); if (violations is null) { return Array.Empty(); } return violations; } public override void EnsureInvariants() { List? violations = null; CollectInvariantViolations(ref violations, out var seamFailure); CollectChildInvariantViolations(ref violations); if (violations is null) { return; } ThrowInvariantViolations(violations, seamFailure); } ``` `CollectChildInvariantViolations` is the part that asks the lines, and the next section is about it. Nothing in the toolkit uses exceptions as control flow to get there, either. An `IInvariant` never throws at all, and the only throw on the way is the one out of your own seam, which is caught in one place so the asking stage can see what it found. ## What one question covers Asking an aggregate root whether it is consistent answers for the whole aggregate: this object's own rules and its seam first, then every child entity it holds, each of which answers for its own children the same way. One call, one list, and the aggregate either may exist or may not. That is what "the aggregate is the consistency boundary" means once it is code instead of prose. The root *is* the boundary, and a boundary that answers only for the object at its centre is not a boundary, it is a field check. A handler that adds a line, asks the order whether that was allowed, and hears nothing about lines has been given an answer about half the aggregate, with no sign that the other half was skipped. A child entity states its own rules in the same two shapes. A line that names nothing is broken whatever order it is on, so that rule belongs to the line: ```csharp public partial class OrderLine { public sealed class MustNameASku : IInvariant { public const string ViolationCode = "LINE_HAS_NO_SKU"; public string Code => ViolationCode; public InvariantFailure? Check(OrderLine line) => string.IsNullOrWhiteSpace(line.Sku) ? "A line must name the thing it is ordering." : null; } } ``` `Order` never mentions it, and asking the order reports it: ```csharp var broken = order.GetInvariantViolations(); // the order's rules, and every line's order.EnsureInvariants(); // the same, and throws instead of answering ``` The walk is generated from the collections the entity declares, so it costs nothing to opt into and nothing to keep true. For `Order` that is the field behind `Lines`: ```csharp title="Order.g.cs, shortened" private void CollectChildInvariantViolations(ref List? violations) { foreach (var child in _lines) { if (child is null) { continue; } var broken = child.GetInvariantViolations(); if (broken.Count == 0) { continue; } violations ??= new List(); for (var index = 0; index < broken.Count; index++) { violations.Add(broken[index]); } } } ``` Each line answers with its own `GetInvariantViolations()`, so a child that held children of its own would ask them in turn. A child collection added next year is asked without anybody editing the root, and a rule added to `OrderLine` is reported by `Order` the day it compiles. ### A handler asks before it saves This is the case the walk exists for, and it is a domain question from end to end: the handler acts on an aggregate it is holding, asks what that broke, and refuses. Loading and saving need a `DbContext`, of course. Deciding whether the aggregate may exist does not, and none takes part in it: the subject is a graph of objects in memory, and nothing a database knows changes the answer. ```csharp public async Task Handle(AddLine command, CancellationToken cancellationToken) { var order = await orders.FindAsync([command.Order], cancellationToken); if (order is null) { return Results.NotFound(); } order.AddLine(command.Sku, command.Quantity); // the domain acts var violations = order.GetInvariantViolations(); // and answers for its lines as well if (violations.Count > 0) { return Results.UnprocessableEntity(violations.Select(v => new { v.Code, v.Message, entity = v.EntityType?.Name, // "OrderLine" id = v.EntityId?.ToString(), // "LINE_0199b1c0-..." })); } await context.SaveChangesAsync(cancellationToken); // still guarded, by the interceptor return Results.Ok(); } ``` Every violation names the entity that reported it, which is the difference between "something in this order is wrong" and "line LINE_0199b1c0 names no SKU". The caller never has to search the aggregate for the object a message was about, and never has to read the message to find out which rule broke, because the code is there to branch on: ```csharp if (violations.FirstOrDefault(v => v.Code == OrderLine.MustNameASku.ViolationCode) is { } blank) { return Results.UnprocessableEntity(new { blank.Code, blank.Message, line = blank.EntityId }); } ``` A named rule is what makes that line possible; a rule left in the seam reports `InvariantViolation.SeamCode` and tells the caller only where it came from. `EntityType` and `EntityId` are nullable because a violation you construct by hand need not carry them. Every violation the toolkit produces does. `EnsureInvariants()` puts the same information in a message. The exception already names the boundary that was asked, so the aggregate's own violations read plainly, and only a child's is prefixed with that child's type and id: ``` The Order 'ORD_0199b1c0' broke 2 invariants: An order may not name the same SKU on two lines. OrderLine LINE_0199b1c2 LINE_HAS_NO_SKU: A line must name the thing it is ordering. ``` That is one line, wrapped here. One `InvariantViolationException`, whatever the seam threw kept as its inner exception, and `Violations` carrying the same messages for anyone who would rather read them than the text. ## When it runs ### At the save The consistency boundary is the transaction. An aggregate is allowed to be inconsistent halfway through one of its own methods; what it promises is that it is never *stored* broken. So that is where the toolkit checks. `UseDDDToolkit` registers an `InvariantInterceptor`. Before every `SaveChanges` it runs the throwing stage on every tracked entity the save adds or modifies, child entities included, and on the aggregate root of each changed child. Anything that throws stops the save, and nothing is written. ```csharp services.AddDDDToolkitEntityFramework(); services.AddDbContext((sp, db) => db.UseSqlite(cs).UseDDDToolkit(sp)); ``` Nothing else to switch on. The argument `AddDDDToolkitEntityFramework` usually takes says how domain events are delivered, which is a separate matter; see [Domain event delivery](https://dylansnel.github.io/DDDToolkit/docs/event-delivery.md). The interceptors run in this order: | Order | Interceptor | Why there | |---|---|---| | 1 | `PublishDomainEventsInterceptor` | Handlers run before the save and may change tracked aggregates. | | 2 | `InvariantInterceptor` | So it sees whatever those handlers changed. | | 3 | `AggregateVersionInterceptor` | Last, so a save the invariants reject leaves no version bumped. | Two things it deliberately does not do. It does not check an entity that is being deleted: a row on its way out has no state left to be consistent about. And it does not check an aggregate that this save does not touch, even when that aggregate is loaded and broken. Rows that were already wrong are a migration problem, not this save's problem. ### Children answer for their own rules A child `[Entity]` has all four members of its own, and the save asks it directly. A line that breaks a rule about lines reports it itself, in its own words, naming its own id, without the root having to know the rule exists. What the save calls on each of them is `EnsureOwnInvariants()`. It already has the whole list in front of it, the root and each changed child alike, so asking any of them to walk would report a child twice: once as itself and once through its root. Only a caller that is not enumerating the graph wants the walking pair, which is the whole of the difference between the two pairs. The list of who gets asked is built from the change tracker and never from a navigation property. That matters more than it sounds: the tracker knows by construction only what was loaded and what changed, so building the list cannot trigger a lazy load, cannot issue a query, and cannot fail on a graph whose children were never loaded. A generated `foreach (var line in _lines)` could do all three, which is why this lives in the persistence layer rather than in the entity. **A rule that spans children still belongs on the root.** "No two lines may name the same SKU" is not a rule of a line; no line can see its siblings, and no line knows it is the one at fault. Only the root knows the whole, and only the root can state a rule about a child this save never touched: ```csharp partial void CheckInvariants() { if (_lines.Select(line => line.Sku).Distinct().Count() != _lines.Count) { throw InvariantViolation("An order may not name the same SKU on two lines."); } } ``` **Do not call `child.EnsureInvariants()` from the root's seam.** The root already asks every child, so a seam that does it again reports every broken line twice: once from the seam and once from the generated walk. The rule about the children, like the one above, is what belongs in the seam. ### By hand All four members are public on every entity and aggregate root, so a unit test or a command handler can ask with no database anywhere: ```csharp var order = new Order(OrderId.CreateUnique(), customer); order.AddLine(sku: "", quantity: 1); // [ InvariantViolation("LINE_HAS_NO_SKU", "A line must name...", typeof(OrderLine), LINE_0199...) ] order.GetInvariantViolations(); // the order's rules and every line's order.EnsureInvariants(); // the same, and throws instead of answering order.GetOwnInvariantViolations(); // the order's own rules only, for a caller walking the graph order.EnsureOwnInvariants(); // the same, and throws ``` For a whole unit of work rather than one aggregate, `InvariantInterceptor` exposes the same two stages over a `DbContext`: ```csharp InvariantInterceptor.CheckInvariants(context); // throws, like the save would var broken = InvariantInterceptor.GetInvariantViolations(context); // asks, and writes nothing ``` Both pick their targets exactly as the interceptor does, so what they report is what the save would do. Neither of them saves. Reach for these when the question spans several aggregates in one unit of work; reach for the aggregate's own pair when you are holding the aggregate, which is most of the time, and when you would rather your domain code did not mention a `DbContext` at all. ## Putting the violations in your own result type The toolkit hands you the failures. It does not hand you a `Result`, for the same reason [value objects](https://dylansnel.github.io/DDDToolkit/docs/value-objects.md#why-not-a-result-type) do not: teams already use FluentResults, ErrorOr, OneOf or something of their own, a result type is infectious, and a codebase with one result type ends up with two. Wrapping the call is a few lines, and they are yours: ```csharp public static Result Consistency(this IHasInvariants entity) { var violations = entity.GetInvariantViolations(); return violations.Count == 0 ? Result.Ok() : Result.Fail(violations.Select(v => new Error(v.Message) .WithMetadata("code", v.Code) .WithMetadata("entity", v.EntityId))); // which child, when it was a child } ``` One call per aggregate is the whole aggregate, children included, so a wrapper like this never needs to know the shape of the graph it is asking about. Which makes the check stage an ordinary step in a handler, with nothing thrown and nothing saved: ```csharp public async Task Handle(AddLine command, CancellationToken cancellationToken) { var order = await orders.FindAsync([command.Order], cancellationToken); order.AddLine(command.Sku, command.Quantity); var consistency = order.Consistency(); if (consistency.IsFailed) { return consistency; // nothing was saved, and nothing was thrown } await context.SaveChangesAsync(cancellationToken); return Result.Ok(); } ``` The `SaveChangesAsync` on the last line is still guarded. The handler asking first does not replace the interceptor; it just means the answer arrives as a value in the one place that wanted it, and the exception stays for the case nobody foresaw. The code is what makes this worth doing. Branch on it, never on the message: ```csharp if (violations.Any(v => v.Code == Order.MustStayWithinTheCreditLimit.ViolationCode)) { return Result.Fail(new CreditLimitExceeded()); } ``` And for a whole request, ask the context rather than each aggregate: ```csharp var violations = InvariantInterceptor.GetInvariantViolations(context); if (violations.Count > 0) { return Results.UnprocessableEntity(violations.Select(v => new { v.Code, v.Message })); } ``` To answer in the reader's language, pass them through `Localized(localizer)` first; see [Localization](https://dylansnel.github.io/DDDToolkit/docs/localization.md). On the throwing path the same violations, codes and arguments included, are on `InvariantViolationException.InvariantViolations`. Violations come back roots before the children they own, so the order is deterministic and reads the way the aggregate is shaped. The aggregate's own walk orders them the same way: this object's rules, then its seam, then each child collection in the order it was declared. ## Why here and not in a validator Validation and invariants answer different questions, and the toolkit keeps them apart on purpose. | | Validation | Invariant | |---|---|---| | Question | "Is this input acceptable?" | "Can this state exist at all?" | | Subject | One value object or one field | The whole aggregate | | Audience | The user who typed it | The programmer who wrote the method | | Toolkit support | `[ValueObject]`, `ValidationError`, FluentValidation | `CheckInvariants()`, `IInvariant` | | Failure | `ValidationError`s you show in a form | `InvariantViolation`, or an exception at the save | A validator sees one object at a time. "An order may not exceed the customer's credit limit" is about the lines, the header and a number that arrived from elsewhere; there is nothing to hang a property rule on. And a validator runs when you ask it to, which means it runs where somebody remembered. The save-time stage runs at the commit, every time, which is the only thing that makes it a guarantee. `InvariantViolation` and `ValidationError` are separate types for the same reason, so code reading one is never handed the other. An `InvariantViolationException` that reaches production is not something to show a user: an aggregate method let its own object into a state the domain says cannot exist, and the fix is in the method or its caller. Catch `ConcurrencyConflictException` and retry; catch a validation failure and report it; an invariant violation is neither. ## The limitation you should know about **An invariant that spans two aggregates cannot be checked this way, and should not be.** "A customer's outstanding orders may not exceed their credit limit" is a rule about a `Customer` and about every `Order` that references it. Neither shape can enforce it. `Order`'s rules only see the order; they would have to load the customer and every sibling order, and even then two concurrent saves could each see a total that was true a moment ago and write a pair that is not. This is not a gap in the toolkit. It is the reason the aggregate boundary exists. A rule you insist on enforcing transactionally is a rule that says those objects are one aggregate, with one root, one version and one lock, which is a real design choice with a real cost in contention. If they are not one aggregate, the rule is eventually consistent, and you have to say what happens in the window: - Raise a domain event (`OrderPlaced`), handle it, and have the handler check the rule and react: reject the order, flag the customer, open a task for a human. See [Domain events](https://dylansnel.github.io/DDDToolkit/docs/domain-events.md) and the [outbox](https://dylansnel.github.io/DDDToolkit/docs/event-delivery.md#the-outbox). - Or move the number that has to be exact inside one aggregate. A `CreditLine` aggregate that holds the reserved amount can guarantee its own total, and the order reserves against it first. Either way, the gap is in your design, and the honest thing is to name it rather than to let a framework pretend it closed it. What the toolkit checks is what it can actually guarantee: one aggregate, one transaction. ## Why a rule is a nested type Nesting is required, not encouraged, and there are two reasons. **It can read private state.** A nested type sees the enclosing type's private members, so `MustHaveLines` above reads `_lines` directly. A rule living anywhere else would have to be handed what it needs, which in practice means widening the aggregate's surface until the rule can see it. Encapsulation lost so that a rule about encapsulation can run is a bad trade. **It is free to discover.** The generator already holds the entity's symbol at the moment it writes the entity, and nested types hang off that symbol. Finding rules by scanning the compilation for implementations of `IInvariant` would make the generated output of every entity depend on every file in the project, which is exactly what stops an incremental generator from being incremental. A file per rule, as another part of the entity, keeps that from turning into one enormous class: ``` Ordering/ Order.cs the aggregate Invariants/ MustHaveLines.cs public partial class Order { public sealed class ... } MustShipSomewhereWeDeliver.cs MustStayWithinTheCreditLimit.cs ``` Two more requirements, both because the generator creates one instance per entity type and reuses it for every check: a rule must be **stateless**, the entity it judges being the argument and never a field, and it must have an **accessible parameterless constructor**. Getting any of this wrong is reported rather than silently ignored, which is the point of the whole design: | | | |---|---| | [DDD00024](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00024) | A rule that is not nested inside an entity, so nothing runs it | | [DDD00025](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00025) | A rule nested inside one type but written about another | | [DDD00026](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00026) | Two rules of one entity answering to the same code | | [DDD00027](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00027) | A rule the generated code cannot construct | A rule that compiles, reads well, has its own passing unit test and never runs is the failure this library exists to make impossible to ship unnoticed. ### Why the seam returns nothing A seam that returned a list would read better than one that throws. It cannot work here. C# only erases a partial method that returns `void`; a partial method with any other return type must have an implementing declaration, so every entity in your solution would be forced to write an empty one. Because the seam is `partial void`, an aggregate that states no invariants at all is left with an `EnsureInvariants` whose compiled body is a single `ret` instruction. There is a test that reads the emitted IL and asserts exactly that, so the claim stays true. For a `Customer` with no rules, no seam and no child collections, the throwing checks the generator writes consist of the call to the seam and nothing else, and the compiler removes that call: ```csharp title="Customer.g.cs, shortened" partial void CheckInvariants(); // ... public override void EnsureOwnInvariants() { CheckInvariants(); } public override IReadOnlyList GetInvariantViolations() => GetOwnInvariantViolations(); public override void EnsureInvariants() { CheckInvariants(); } ``` Named rules do not have that constraint, which is the other thing they buy: an `IInvariant` is an ordinary type with an ordinary return value, and returning `null` for "fine" costs nothing. ## Collections, and not single references The walk covers the child entity collections a type declares, and it reads the generated backing field rather than the read-only property. It does not follow a single reference from one entity to another. That is a decision, not a missing line. A child holding a property back to its parent is an ordinary shape, and following single references would walk root, child, root, child until the stack ran out. Stopping that needs a visited set, and a visited set is an allocation on every call, including the overwhelming majority where nothing is broken at all. The consistent path through an invariant check allocates nothing today, and it runs for every changed entity on every save; paying for a `HashSet` there to support a shape an aggregate does not have is the wrong trade. What an aggregate does have is a root owning collections of children. Ruling cycles out by construction costs nothing and misses nothing real. A single reference to *another* aggregate is not a child at all, it is an id, and that aggregate's consistency is [its own problem](https://dylansnel.github.io/DDDToolkit/docs/invariants.md#the-limitation-you-should-know-about). ## What the save asks, and why the two never disagree The domain walk and the save-time pass have different subjects, on purpose. | | The domain walk | The save | |---|---|---| | Asked of | an aggregate you are holding | a `DbContext` | | Reaches | every child in the graph | every tracked entity this save adds or modifies | | Finds them | through the generated `foreach` over `_lines` | through the change tracker | | Queries the database | never, and it cannot lazy load | never, and it reads no navigation | | Called by | your handler, when it wants to know | `InvariantInterceptor`, before every `SaveChanges` | | Calls | `EnsureInvariants` / `GetInvariantViolations` | `EnsureOwnInvariants` / `GetOwnInvariantViolations` | All four members live on `IHasInvariants`, so the persistence layer can ask any entity without knowing its id type. The domain walk asks more objects. The save asks the ones it is about to write, because a save deals in partial graphs: a root loaded without its collection, a child the unit of work never saw, an aggregate assembled from two queries. A generated `foreach` over `_lines` on a graph like that could trigger a lazy load, could issue a query and could fail, which is exactly why finding who to ask lives in the persistence layer rather than in the entity. Which is also why "the collection might not be loaded" does not get to shape this API. It is a fact about persistence, it is answered in persistence, and the aggregate a handler is working on is in memory and whole, because that is the only state an aggregate is ever in while a handler works on it. The consequence is the one worth having: **the domain answer is the stricter of the two**. Everything the save would ask is a subset of what the walk already asked, so an aggregate that answers clean is never contradicted at the commit. The reverse does not hold, and should not: the walk reports a broken line that the save would pass over because nothing about it changed. A rule broken by a row nobody touched is still a broken rule when you ask the domain, and is still not this save's problem. ## When to ask about one object only `EnsureOwnInvariants()` and `GetOwnInvariantViolations()` answer for one object and say nothing about what it holds. There is one reason to want them, and it is narrow: **you are walking the graph yourself** and asking every object you find. Ask the walking pair from inside such a walk and every child is reported twice, once by itself and once by its root. The interceptor is that caller, which is why it asks the self-only pair, and a hand-written traversal of your own is the same situation. Anything holding an aggregate and wanting to know whether it is consistent wants `GetInvariantViolations()`. If you are reaching for the self-only pair to avoid a collection you are not sure is loaded, you are in the persistence case, and [`InvariantInterceptor`](https://dylansnel.github.io/DDDToolkit/docs/invariants.md#by-hand) already handles it from the change tracker. There is one more place the pair matters: an entity you write by hand rather than generate. State its rules in `EnsureOwnInvariants` / `GetOwnInvariantViolations`, because `Entity`'s walking pair delegates to the self-only one. Override only `GetInvariantViolations` and the object still answers for itself, but the save, which asks the self-only member, hears nothing at all. ## What this costs An entity that states nothing and holds no children pays nothing at run time. The compiler removes an unimplemented partial method and every call to it, no array of rules is emitted when there are none, no walk is emitted when there is nothing to walk, and the generated `EnsureInvariants` is an empty method body the interceptor calls for nothing. An entity that does state rules pays for one static array, built once per entity type and never again, and one virtual call per rule per check. The list of violations is allocated on the first failure and never on the consistent path, which is the path that runs for every changed entity on every save. There is no reflection, no attribute scan, no expression tree and no LINQ anywhere in the generated code. The walk pays for a `foreach` over each child collection and one call per child, and a child whose answer is empty adds nothing to the list, because there is no list yet. A consistent aggregate of a root and a hundred lines therefore allocates nothing at all, which is what lets the domain question be asked on a hot path without anybody having to think about it. The walk is emitted only over collections whose element type is an `[Entity]` or `[AggregateRoot]`, so a collection of value objects, identifiers or strings costs nothing. The interceptor walks the change tracker to decide who to ask, which is the same walk `AggregateVersionInterceptor` already does for versioning, and it reads no navigation properties at all. ## See also - [Entities and aggregates](https://dylansnel.github.io/DDDToolkit/docs/entities-and-aggregates.md) for what belongs inside the boundary. - [Value objects](https://dylansnel.github.io/DDDToolkit/docs/value-objects.md) for validation of a single value, which is the other half. - [Entity Framework](https://dylansnel.github.io/DDDToolkit/docs/entity-framework.md) for the interceptors and what each one guarantees. - [Testing aggregates](https://dylansnel.github.io/DDDToolkit/docs/testing.md#testing-invariants) for asserting on invariants in a unit test. - [Diagnostics](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00024) for DDD00024 to DDD00027, the four ways a rule can be written so that it never runs. # Domain events When an order is cancelled, other things have to follow: the stock set aside for it is released, the customer is told, the payment is refunded. Written into `Cancel`, each of those drags a dependency into the aggregate (a mailer, a stock service, a payment client), and the aggregate ends up knowing every part of the system that cares about it. Adding one more reaction means editing the order. A domain event records something that happened, and stops there. The order says that it was cancelled; whoever cares reacts. Side effects stay out of the aggregate, and other aggregates and other modules can react to it without the aggregate knowing they exist. The toolkit gives every event an identity and a timestamp, restricts raising to the aggregate that owns the event, and restricts draining to the persistence layer. This page declares an event, raises it and reads it back, then covers delivery, the identity and the timestamp, and the stable name an event needs once it is stored. ## Declaring an event An event is a record deriving from `DomainEvent`, named for what happened: ```csharp using DDDToolkit.BaseTypes; public sealed record OrderCancelled(OrderId OrderId, CancellationReason Reason) : DomainEvent; ``` `DomainEvent` supplies: ```csharp public Guid EventId { get; init; } // Guid.CreateVersion7(), time ordered public DateTimeOffset OccurredAt { get; init; } // UtcNow ``` An event needs no attribute and no `partial`: it is an ordinary record. What the two properties are for is under [Why the id and the timestamp](https://dylansnel.github.io/DDDToolkit/docs/domain-events.md#why-the-id-and-the-timestamp). ## Raising Raising is `protected` on `AggregateRoot`, so an event can only be created by the aggregate it describes: ```csharp [AggregateRoot] public partial class Order { public void Cancel(CancellationReason reason) { if (Status == OrderStatus.Shipped) { throw new InvalidOperationException("A shipped order cannot be cancelled."); } Status = OrderStatus.Cancelled; RaiseDomainEvent(new OrderCancelled(Id, reason)); } } ``` Nothing outside can inject an event into an aggregate. That matters when events are your integration contract: an event that says the order was cancelled should only exist if the order actually cancelled. Child entities marked `[Entity]` have no event list. A change to a child is a change to its aggregate, so the root raises the event. ## Reading and draining ```csharp IReadOnlyList pending = order.DomainEvents; ``` `DomainEvents` is a read-only view, marked [`[Internal]`](https://dylansnel.github.io/DDDToolkit/docs/value-objects.md#hiding-members), the toolkit's marker for infrastructure members, so it stays out of your tables, your JSON and your GraphQL schema. Draining is deliberately awkward to reach. `IHasDomainEvents` is implemented explicitly: ```csharp public interface IHasDomainEvents { IReadOnlyList DomainEvents { get; } IReadOnlyList DequeueDomainEvents(); // returns and empties void ClearDomainEvents(); // discards } ``` ```csharp var events = ((IHasDomainEvents)order).DequeueDomainEvents(); ``` You will not normally write that line: the Entity Framework integration does it during save. The cast is the point. Application code that can silently call `ClearDomainEvents()` can write a row whose event never happened, and in a system where events are the integration contract that is a data integrity problem, not a style issue. ## Delivery Raising an event puts it in a list on the aggregate. Getting it to a handler is a separate concern with a real trade-off, handled by `DDDToolkit.EntityFramework`. ```mermaid flowchart LR Raise["RaiseDomainEvent(...), inside the aggregate"] --> Pending["pending on the aggregate"] Pending --> Save["SaveChanges takes them"] Save -->|"in process"| Handlers["your handlers, inside the save"] Save -->|"outbox"| Rows["outbox rows, delivered after the commit"] ```
Show the code: choosing how events are delivered One line in the registration decides, and the aggregate does not change: ```csharp // in process: handlers run inside the save builder.Services.AddDDDToolkitEntityFramework(options => options.DispatchWithMediator()); // through the outbox: rows in the same transaction, delivered afterwards builder.Services.AddDDDToolkitEntityFramework(options => { options.DispatchWithMediator(); options.UseOutbox(outbox => outbox.RegisterEventsFromAssemblyContaining()); }); builder.Services.AddOutboxBackgroundService(TimeSpan.FromSeconds(2)); ``` [Delivering domain events](https://dylansnel.github.io/DDDToolkit/docs/event-delivery.md) draws both, step by step, with the rest of the setup.
**In-process dispatch** runs handlers during `SaveChanges`, before the commit. Handler changes to the same `DbContext` ride along in the same transaction, and a throwing handler aborts the save. It is simple and transactional, but it is best-effort: nothing survives a process crash, and handlers must be fast because they hold the transaction open. **Outbox delivery** writes one row per event in the same transaction as the aggregate, and a separate reader delivers them afterwards. The write is atomic with the data, so an event is never lost and never published for a transaction that rolled back. Delivery is at-least-once, so handlers must be idempotent, keyed on `EventId`. Choose in-process for side effects inside the same database, and the outbox for anything that leaves the process. The configuration for both is in [Domain event delivery](https://dylansnel.github.io/DDDToolkit/docs/event-delivery.md). ## Why the id and the timestamp `EventId` is the idempotency key. Any at-least-once delivery mechanism will hand the same event to a handler twice eventually, and the handler needs a stable way to recognise it. `OccurredAt` records when the thing happened, which is not the same as when a handler runs. Both are `init`, so code that constructs the event can supply its own values: ```csharp var evt = new OrderCancelled(id, reason) { OccurredAt = recorded }; ``` That covers replaying events you already have. It does not cover testing, because the code that constructs an event is the aggregate, not your test. See [Deterministic time in tests](https://dylansnel.github.io/DDDToolkit/docs/testing.md#deterministic-time-in-tests). You can implement `IDomainEvent` directly instead of deriving from `DomainEvent`; you then provide `EventId` and `OccurredAt` yourself. ## Stable names As long as an event only lives in memory, its name does not matter. Once it is stored or published, a name is written down with it, in an outbox row or on a message, and read back later by code that may have been renamed in between. So every event has a name, and you rarely have to write it. ### The convention An event nobody named is named after its module and its class, both in kebab case. The module is the one the assembly declares with `[assembly: Module]` ([Modules](https://dylansnel.github.io/DDDToolkit/docs/modules.md)): ```csharp [assembly: Module("Ordering")] public sealed record OrderPlaced(OrderId OrderId, CustomerId Customer) : DomainEvent; // ordering.order-placed public sealed record HTTPCallbackReceived(string Url) : DomainEvent; // ordering.http-callback-received ``` ```csharp DomainEventName.Of(); // "ordering.order-placed" DomainEventName.Of(someEvent); // same, from an instance ``` An assembly without `[assembly: Module]` leaves the module out: `order-placed`. The namespace plays no part, so moving a class to another namespace or folder changes nothing. ### Versions are in the class name A class name that ends in `V` and a number is that version of its event, and the suffix is not part of the name. Every version of one event shares the name: | Class | Name | Version | |---|---|---| | `OrderPlaced` | `ordering.order-placed` | 1 | | `OrderPlacedV2` | `ordering.order-placed` | 2 | | `Level2Reached` | `ordering.level2-reached` | 1, the digits do not follow a `V` | A class can also state its version with `[IntegrationEvent(Version = n)]`, and a stated version wins over the one in the name: it is the one somebody wrote on purpose, the suffix is the convention for when nobody did. When a class does both and they differ, the suffix is ignored and the build warns, [DDD00034](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00034), with a fix that renames the class to the version it is. A suffix that cannot be a version, `V0` or `V01`, fails the build with [DDD00035](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00035). What a version is for is in [Versioning and upcasting](https://dylansnel.github.io/DDDToolkit/docs/integration-events.md#versioning-and-upcasting). ### Renaming a class The conventional name follows the class. Rename `OrderPlaced` to `PlacedOrder` and new rows are written as `ordering.placed-order`, while the rows already in the outbox, and every consumer, still say `ordering.order-placed`. So a rename is the moment to pin the old name: ```csharp [DomainEventName("ordering.order-placed")] public sealed record PlacedOrder(OrderId OrderId, CustomerId Customer) : DomainEvent; ``` A published contract pins its name in its own attribute, `[IntegrationEvent("ordering.order-placed")]`. The version still comes from the class name, so `PlacedOrderV2` with that attribute is `ordering.order-placed` version 2. ### Two events with one name Two classes of one module can have the same name in different namespaces, and the convention then gives both the same event name. An outbox row or a message could not say which of the two it is, so that is a compile error, [DDD00036](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00036), on both classes: ```csharp namespace Ordering.Orders { public sealed record OrderPlaced(OrderId OrderId) : DomainEvent; } namespace Ordering.Returns { public sealed record OrderPlaced(OrderId OrderId) : DomainEvent; } // error DDD00036: 'Ordering.Returns.OrderPlaced' and 'Ordering.Orders.OrderPlaced' are both stored or // published as 'ordering.order-placed' version 1 ... ``` The code fix pins another name on the class you invoke it on, taken from its namespace or containing type: `[DomainEventName("ordering.returns-order-placed")]`. Or rename one of the classes. The toolkit does not make the names unique by itself, from the namespace for instance, because then a name would change the moment a class moved, or the moment a second class of the same name appeared, and the rows written under the old one would be orphaned without a word. Only events of one kind are compared: | These two | Error? | |---|---| | Two domain events under one name and version | Yes | | Two published contracts under one name and version, such as `OrderPlaced` and `OrderPlacedV1` | Yes | | A domain event and the contract it is published as, `OrderPlaced` and `OrderPlacedV1` | No, that is the pattern | | `OrderPlacedV1` and `OrderPlacedV2` | No, those are versions | The check runs where the module compiles, so it sees that assembly's events. Two assemblies that declare the same module without referencing each other are still compared when the application starts, by the outbox registry, which refuses the second. ### The names as constants For every name its events are stored or published under, the generator writes a constant into a class named after the module: ```csharp title="EventNames.g.cs" public static class OrderingEventNames { /// ordering.order-placed: OrderPlaced (version 1), OrderPlacedV2 (version 2). public const string OrderPlaced = "ordering.order-placed"; } ``` Use them where a name is written by hand: a topic binding, a test, a log query. Not in an event's own `[DomainEventName]` or `[IntegrationEvent]`: the generator reads those attributes to write the constants, so a constant cannot be what names its own event. Write the literal there. The constant is named after the name, not the class, so a renamed class that pins its old name keeps its old constant. A contracts assembly, where every name belongs to a published contract, marks the class `[ModuleContract]` so other modules can use it. ### Where the name ends up - **The outbox row** stores it in `EventName`, and the generated registration writes it out as a literal when the module compiles, so nothing reads an attribute at start-up. - **The published message** carries it in its `Name`, which is what an inbox and a pgmq topic route on. - **A broker exchange**, with MassTransit or Wolverine and `UseIntegrationEventNames()`, is the name and the version, `ordering.order-placed.v1` ([Transports](https://dylansnel.github.io/DDDToolkit/docs/transports.md)). ```csharp title="IntegrationEventExtensions.g.cs, shortened" public static OutboxOptions AddOrderingIntegrationEvents(this OutboxOptions outbox) { ArgumentNullException.ThrowIfNull(outbox); outbox.RegisterEvent("ordering.order-cancelled", 1); outbox.RegisterEvent("ordering.order-placed", 1); return outbox; } ``` The `1` is the version of the event's shape, which matters once the shape changes. See [Registered when the module compiles](https://dylansnel.github.io/DDDToolkit/docs/integration-events.md#registered-when-the-module-compiles) for the rest of what that method registers. ### Rows written by an older build Before events were named by convention, an event without `[DomainEventName]` was stored under its bare class name, `OrderPlaced`. The outbox still finds such a row: every registered type answers to its class name as well, as long as no current name is spelled the same and no other registered class has that class name. A row found that way is read as exactly that class, upcast if the class is an older shape, and published under the name the type has now. ## Testing Events make aggregates testable without a database: act, then assert on what was raised. `DDDToolkit.Testing` does the asserting, and reports only the events the call under test raised: ```csharp AggregateScenario.Given(order) .When(o => o.Cancel(CancellationReason.OutOfStock)) .RaisedExactly(); ``` See [Testing aggregates](https://dylansnel.github.io/DDDToolkit/docs/testing.md), which also covers fixing the clock an event is stamped with, in [Deterministic time in tests](https://dylansnel.github.io/DDDToolkit/docs/testing.md#deterministic-time-in-tests). # Designing aggregates Declaring an aggregate is one attribute. Deciding what belongs inside it is the part that matters, and the part no attribute does for you. An aggregate is a trade. Everything inside the boundary is consistent at the end of every transaction: its rules are checked at every save, and one version guards all of it, so two writers cannot change it at once without one of them being refused. The price is paid on every load and every save. The whole aggregate is loaded to change any part of it, and a write to any part conflicts with a write to any other. Draw the boundary too wide and you pay that price for rules that did not need it. Draw it too narrow and a rule that must always hold can be broken between two saves. Neither mistake shows where it is made. Both compile, and both read reasonably in review. That is why the pattern comes with rules of thumb, and why they are worth knowing before the first aggregate rather than after the tenth. This page goes through them and says where the toolkit helps and where it does not. [Entities and aggregates](https://dylansnel.github.io/DDDToolkit/docs/entities-and-aggregates.md) has the mechanics. ## The four rules of aggregate design Vaughn Vernon's *Effective Aggregate Design* gives four rules of thumb. They are the best short summary of the pattern anyone has written, and they are a fair way to ask what a toolkit is actually worth. Here is how this one scores against them. | Rule | What the toolkit does | |---|---| | 1. Model true invariants in consistency boundaries | Gives you the boundary, the token, and two places to state a rule, both run at every commit. You write the rules. | | 2. Design small aggregates | Nothing. Arguably it makes large ones easier to build. | | 3. Reference other aggregates by identity | Generates the identity and warns when you do not use it. | | 4. Use eventual consistency outside the boundary | Domain events, the outbox and the inbox. | ### Rule 1: model true invariants in consistency boundaries Supported, as far as a library can go. The rules are still yours to write; where they run is not. `[AggregateRoot]` draws the boundary and `Version` makes it real: one save is one aggregate, and a second writer with a stale version is refused rather than merged. Child entities are owned, so they load and save with the root and cannot be written behind its back. Private setters and a generated protected constructor mean the only way into the state is through a method you wrote. On top of that, every entity and aggregate root gets somewhere to state its rules, either a generated `partial void CheckInvariants()` seam or a nested `IInvariant` per rule, and `UseDDDToolkit` registers an interceptor that runs them before every `SaveChanges` that writes the entity. A broken rule stops the save. [Invariants](https://dylansnel.github.io/DDDToolkit/docs/entities-and-aggregates.md#invariants) on the entities page shows the seam. Asking the root asks the whole aggregate: its own rules and then every child entity it holds, which is Vernon's first rule stated in code rather than in prose. A boundary that answered only for the object at its centre would not be one. That is the whole of what a library can promise here: the place to write the rule, the guarantee that it runs at the commit rather than wherever somebody remembered, and one call that covers everything inside the boundary. [Invariants](https://dylansnel.github.io/DDDToolkit/docs/invariants.md) has both shapes of rule, the second stage that asks instead of throwing, what one question covers, the interceptor order, and the limitation that matters, which is that an invariant spanning two aggregates cannot be checked this way and should not be. A guard clause in the method that makes the change is still right, and the two are not in competition. The guard refuses the command with a message the caller can act on; the seam is the net under every path into the state, including the ones you add next year: ```csharp public void Ship(TrackingCode code) { if (Status != OrderStatus.Paid) { throw new InvalidOperationException("Only a paid order can ship."); } Status = OrderStatus.Shipped; RaiseDomainEvent(new OrderShipped(Id, code)); } ``` Value objects are a different thing again: `[ValueObject]` and `[SingleValueObject]` carry rules through `Validate` and the always-valid twin. See [Value objects](https://dylansnel.github.io/DDDToolkit/docs/value-objects.md). That is validation of one value, not an invariant across a cluster, and the two are worth keeping apart in your head. [Invariants](https://dylansnel.github.io/DDDToolkit/docs/invariants.md#why-here-and-not-in-a-validator) lays the two side by side. The word "true" in the rule is doing the work anyway, and no tool can check it. A true invariant is a rule that must hold at the end of every single transaction. A rule that may be a minute late is not one, and dragging it inside the boundary to be safe is how aggregates get big. ### Rule 2: design small aggregates Not supported. This is the rule the toolkit is least help with, and on one reading it works against it. The mechanism is [read-only collections](https://dylansnel.github.io/DDDToolkit/docs/entities-and-aggregates.md#read-only-collections). Declaring one is a single line: ```csharp public partial IReadOnlyList Lines { get; } ``` and you get the backing field, the read-only view and the Entity Framework mapping. That is a good feature, and the cost of it is that the moment of friction is gone. Writing the field, the view and the `[BackingField]` by hand takes a minute, and a minute is long enough to wonder whether the collection belongs there. A one-line declaration is not. Nothing downstream catches it either. `[BackingField]` maps whatever you declared. [DDD00021](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00021) checks the *type* in the collection, not the size of it: it stops `IReadOnlyList` and says nothing at all about `IReadOnlyList` holding a hundred thousand lines. There is no diagnostic for aggregate size, and there is not going to be a useful one, because "too big" is a question about your invariants and your write patterns and a compiler can see neither. So the check is yours. Before adding a collection to an aggregate, ask: **Does a rule in this aggregate read the whole collection?** Not one element, all of them. "The order total may not exceed the credit limit" reads every line, so the lines belong inside. If no rule needs the collection as a whole, it is not part of any invariant, and you are storing a query result in an object graph. **Can an element exist without this root?** If a `Customer` can outlive an `Order`, the collection is a reference to another aggregate wearing a collection's clothes. Typed as `IReadOnlyList` the analyzer catches it. Typed as `IReadOnlyList`, where `CustomerSummary` is a child entity you invented to get around it, nothing catches it and the boundary is just as broken. **Is it bounded by something the domain guarantees?** "An order has lines" is bounded by what one person will buy in one go. "A customer has orders" is bounded by nothing: it grows for as long as the customer stays. Unbounded collections are where aggregates go wrong, and they are obvious in the domain language long before they are obvious in a profiler. **How many people write to it at once?** Every write to any element takes the root's `Version`. Two users adding a line to the same order is fine. Two hundred warehouse scanners adding events to the same shipment is a queue of `ConcurrencyConflictException`, and the fix is a smaller aggregate, not a retry loop. **What would break if this were a list of ids?** Often the honest answer is "a few queries would get longer". That is the trade, and it is usually the right one. A large aggregate does not fail a build or a test. It fails in production, as lock contention and as `SaveChanges` calls that load more than they needed, and by then it is in your schema. The toolkit gives you no warning about it. This section is the warning. ### Rule 3: reference other aggregates by identity Supported, and checked. `[EntityId]` gives you the id type, `[AggregateRoot("ORD")]` generates it for you, and [DDD00021](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00021) reports a field or property typed as another root. The analyzer knows which of your types are roots and which are ids because the attributes told it, so this is one of the few DDD rules a tool can genuinely check rather than lecture about. It is a warning, not an error, and it covers fields and properties only. A method that takes another root as a parameter is fine and is often the right shape: `order.PlaceFor(customer)` reads better than `order.PlaceFor(customer.Id)` and stores the id either way. [Reference other aggregates by id](https://dylansnel.github.io/DDDToolkit/docs/entities-and-aggregates.md#reference-other-aggregates-by-id) has the full reasoning and the one navigation that is allowed. ### Rule 4: use eventual consistency outside the boundary Supported, and it is the part of the toolkit with the most machinery behind it. An aggregate raises a domain event. The event leaves the boundary, and whatever it touches catches up afterwards: - `RaiseDomainEvent` inside the aggregate, drained by the persistence layer. See [Domain events](https://dylansnel.github.io/DDDToolkit/docs/domain-events.md). - The [outbox](https://dylansnel.github.io/DDDToolkit/docs/event-delivery.md#the-outbox) writes one row per event in the same transaction as the aggregate, so the event cannot be lost when the save succeeds or survive when it fails. - [Integration events](https://dylansnel.github.io/DDDToolkit/docs/integration-events.md) give the event a published contract and a sink, so the thing catching up can be in another process. - The [inbox](https://dylansnel.github.io/DDDToolkit/docs/integration-events.md#the-inbox-on-the-other-side) makes the receiving side idempotent, which is what at-least-once delivery requires of it. What this does not do is make eventual consistency free. Delivery is at-least-once and ordering is best-effort, a handler can fail after other handlers succeeded, and a reader can see one aggregate updated and another not yet. Those are properties of the approach, not gaps in the implementation, and the pages above say where each one bites. The rule that matters here is the one about rule 1: if you find yourself wanting a transaction across two aggregates, the question is whether the rule forcing it is really a true invariant. If it is, the two aggregates are one. If it is not, an event is the answer. # Testing aggregates An aggregate is pure domain logic. No database, no clock, no network. It should be the easiest thing in your system to test, and the test should read like the rule it encodes. `DDDToolkit.Testing` is a small package that makes that true for domain events. Without it every assertion about a raised event starts with a cast: ```csharp var events = ((IHasDomainEvents)order).DequeueDomainEvents(); events.Should().ContainSingle(e => e is OrderCancelled); ``` That cast is deliberate, because [draining events is the persistence layer's job](https://dylansnel.github.io/DDDToolkit/docs/domain-events.md#reading-and-draining). A test is the one place it gets in the way. This package writes it for you, and gives you failures that say what went wrong. ```bash dotnet add package Temp.DDDToolkit.Testing ``` The package references `DDDToolkit` and nothing else. It brings no test framework and no assertion library, so it works the same under xunit, NUnit, MSTest, FluentAssertions, Shouldly or plain `if`-and-throw. A failed assertion throws `AggregateAssertionException`, which every runner reports as a failed test. ## A complete test Here is the aggregate. It is an ordinary declaration, nothing testing-specific about it: ```csharp [AggregateRoot("ORD")] public partial class Order { public Order(OrderId id, CustomerId customer) : base(id) { Customer = customer; RaiseDomainEvent(new OrderPlaced(id, customer)); } public CustomerId Customer { get; private set; } public OrderStatus Status { get; private set; } = OrderStatus.Draft; public partial IReadOnlyList Lines { get; } public void AddLine(Sku sku, int quantity) { if (quantity <= 0) { throw new ArgumentOutOfRangeException(nameof(quantity), quantity, "A line needs at least one item."); } if (Status != OrderStatus.Draft) { throw new InvalidOperationException($"A {Status} order cannot take new lines."); } _lines.Add(new OrderLine(sku, quantity)); RaiseDomainEvent(new LineAdded(Id, sku, quantity)); } public void Cancel(string reason) { if (Status == OrderStatus.Shipped) { throw new InvalidOperationException("A shipped order cannot be cancelled."); } if (Status == OrderStatus.Cancelled) { return; } Status = OrderStatus.Cancelled; RaiseDomainEvent(new OrderCancelled(Id, reason)); } } ``` And here is the test: ```csharp using DDDToolkit.Testing; public class OrderTests { private static Order Draft() => new(OrderId.CreateUnique(), CustomerId.CreateUnique()); [Fact] public void AddingALineRecordsIt() { AggregateScenario.Given(Draft()) .When(order => order.AddLine(Sku.Of("SKU-1"), 3)) .RaisedExactly(); } [Fact] public void ALineCarriesWhatWasOrdered() { var order = Draft(); AggregateScenario.Given(order) .When(o => o.AddLine(Sku.Of("SKU-1"), 3)) .Raised(new LineAdded(order.Id, Sku.Of("SKU-1"), 3)); } [Fact] public void CancellingTwiceCancelsOnce() { var order = Draft(); order.Cancel("out of stock"); AggregateScenario.Given(order) .When(o => o.Cancel("out of stock")) .RaisedNothing(); } [Fact] public void AnEmptyQuantityIsRefusedBeforeAnythingHappens() { AggregateScenario.Given(Draft()) .WhenThrows(order => order.AddLine(Sku.Of("SKU-1"), 0)); } } ``` Three things are worth pointing out. `Given` takes the aggregate your test built. It does not replay an event stream, because a DDDToolkit aggregate is not event sourced. The arrange step is the constructor and whatever methods put the aggregate into the state the rule is about. Anything else would be pretending. `When` reports only the events that call raised. The `OrderPlaced` from the constructor is still on the aggregate, but it is not in the batch you assert on, so you never have to filter it out. `WhenThrows` asserts two things: that the expected exception came out, and that nothing was raised on the way out. That second half is the one people forget. An aggregate that raises `OrderCancelled` and then decides the order cannot be cancelled has published something that never happened. ## The assertions Every method below is on the batch that `When` returns. They all return the batch, so they chain. | Assertion | Passes when | |---|---| | `Raised()` | At least one `T` was raised. A subtype of `T` counts. | | `Raised(e => ...)` | At least one `T` satisfies the predicate. | | `Raised(expected)` | An event carries that payload. `EventId` and `OccurredAt` are ignored. | | `RaisedNo()` | No `T` was raised. | | `RaisedNothing()` | Nothing at all was raised. | | `RaisedExactly()` | Exactly these types, in this order, and nothing else. | | `RaisedExactly(params Type[])` | The same, for more than three types. | | `RaisedExactlyThese(params IDomainEvent[])` | Exactly these events, in this order, compared by payload. | | `SingleEvent()` | Exactly one `T` was raised. Returns it. | | `EventsOf()` | Never fails. Returns every `T` that was raised. | The batch is also an `IReadOnlyList`, so anything the table does not cover you can still do with LINQ and your own assertion library: ```csharp var raised = AggregateScenario.Given(order).When(o => o.AddLine(sku, 3)); raised.SingleEvent().Quantity.Should().BeGreaterThan(0); raised.Select(e => e.OccurredAt).Should().BeInAscendingOrder(); ``` ### Comparing payloads `Raised(expected)` compares the payload and nothing else: ```csharp .Raised(new LineAdded(order.Id, Sku.Of("SKU-1"), 3)); ``` That comparison exists because record equality cannot do the job. Two events with identical payloads are never equal: `EventId` is a fresh `Guid` on every instance and `OccurredAt` is the moment of construction. So `Assert.Equal(expected, raised)` fails every time, and teams work around it by asserting member by member. The kit compares every public member of the event except the two the `IDomainEvent` contract supplies, and reports the first one that differs. Collections inside a payload compare element by element, so a `IReadOnlyList` built with a different concrete list type still matches. ## Failure messages A testing package with unhelpful failures is worse than no package. Every message names the expectation and lists what was actually raised, with payloads: ```text Expected Order to raise exactly OrderShipped. The first difference is at index 0: expected OrderShipped, was OrderConfirmed. 1 event was raised while running the action: [0] OrderConfirmed { OrderId = ORD_edbace54-787b-4345-bd30-bff77a479567, LineCount = 1 } ``` ```text Expected Order to raise OrderCancelled { OrderId = ORD_edba..., Reason = "duplicate order" }, but no raised event carried that payload. OrderCancelled { OrderId = ORD_edba..., Reason = "out of stock" }: Reason was "out of stock", expected "duplicate order" 1 event was raised while running the action: [0] OrderCancelled { OrderId = ORD_edba..., Reason = "out of stock" } EventId and OccurredAt are never compared. ``` ```text SloppyOrder threw InvalidOperationException as expected, but it raised 1 event first. An aggregate that raises an event and then refuses the command leaves an event describing something that never happened. 1 event was raised while running the action: [0] OrderCancelled { OrderId = ORD_00000000-0000-0000-0000-000000000000, Reason = "late" } ``` ## Scenarios with more than one step Keep the scenario in a variable and assert step by step. Each `When` sees only its own events, so nothing needs draining in between: ```csharp var scenario = AggregateScenario.Given(Draft()); scenario.PendingEvents.RaisedExactly(); scenario.When(o => o.AddLine(Sku.Of("SKU-1"), 2)).RaisedExactly(); scenario.When(o => o.Confirm()).RaisedExactly(); scenario.When(o => o.Ship("TRACK-1")).RaisedExactly(); scenario.Subject.TrackingCode.Should().Be("TRACK-1"); ``` | Member | What it does | |---|---| | `Subject` | The aggregate, for asserting on its state. | | `PendingEvents` | Everything the aggregate is holding, without taking it off. | | `Drain()` | Takes the events off, the way a save does, and returns them. | | `IgnorePendingEvents()` | Throws away what the arrange step raised. | `Drain()` is worth using when the scenario spans something that would have been a save. It models what `SaveChanges` does to the aggregate, so the second half of the test starts from the state the second request would really see. Asynchronous methods have `WhenAsync` and `WhenThrowsAsync`, which take a `Func`. ## Without a scenario For a test that calls one method, two extension methods on `IHasDomainEvents` are enough: ```csharp order.Cancel("out of stock"); order.PendingEvents().RaisedExactly(); ``` `PendingEvents()` leaves the events on the aggregate. `DrainEvents()` takes them off and returns the same kind of batch. `AsScenario()` is the same thing as `AggregateScenario.Given`. ## Testing invariants An invariant needs no scenario and no database either. Both stages are public on every entity, so a test asks the aggregate directly and your own assertion library does the rest. Say the order has a named rule, `Order.MustHaveLines`, that a confirmed order must have at least one line ([Invariants](https://dylansnel.github.io/DDDToolkit/docs/invariants.md#a-named-invariant) shows how to write one): ```csharp var order = Draft(); order.Confirm(); Assert.Throws(() => order.EnsureInvariants()); // Or without an exception, asserting on the rule rather than on the message: order.GetInvariantViolations().Should().ContainSingle(v => v.Code == Order.MustHaveLines.ViolationCode); ``` Prefer the second form. The code is the rule's name and stays put; the message is written for people, and may be reworded or translated. Asking the root asks the whole aggregate, so a test for a child's rule needs no loop and no second subject. The violation names the child that reported it, which is what the assertion should be about: ```csharp var order = Draft(); order.AddLine(Sku.Of(""), 1); order.GetInvariantViolations().Should().ContainSingle(v => v.Code == OrderLine.MustNameASku.ViolationCode && v.EntityId!.Equals(order.Lines[0].Id)); ``` A rule written as a nested `IInvariant` is also an ordinary type, so it can be tested on its own with no aggregate mutation and nothing to catch: `new Order.MustHaveLines().Check(order)` returns `null` when the rule holds. See [Invariants](https://dylansnel.github.io/DDDToolkit/docs/invariants.md#by-hand). ## Deterministic time in tests `OccurredAt` and `EventId` are `init`, but the aggregate is what calls `new OrderCancelled(...)`. A test that calls `order.Cancel(reason)` never reaches that constructor, so it cannot pass an initialiser and the event is stamped with the wall clock. `DomainEventClock` replaces the clock both initialisers read, for the current asynchronous flow only. It belongs to the core package rather than to this one, because nothing here compares `EventId` or `OccurredAt`; it is there for a test that asserts on the time itself: ```csharp using DDDToolkit.BaseTypes; var clock = new FakeTimeProvider(new DateTimeOffset(2024, 1, 21, 12, 0, 0, TimeSpan.Zero)); using var scope = DomainEventClock.Use(clock); AggregateScenario.Given(Draft()) .When(order => order.Cancel("out of stock")) .Raised(e => e.OccurredAt == clock.GetUtcNow()); ``` Any `TimeProvider` will do. The toolkit does not ship a fake one: `FakeTimeProvider` from `Microsoft.Extensions.TimeProvider.Testing` already exists, and a five-line subclass of `TimeProvider` works just as well. The clock is held in an `AsyncLocal`, not in a static property, so it applies to the flow that opened the scope and to whatever that flow calls, including awaited work. Two test classes running in parallel do not see each other's clock, and a test that forgets to dispose the scope cannot poison the tests that run after it. Disposing restores the previous clock, so scopes nest. It does not reach work that was already running when you opened the scope, such as a hosted service started earlier; events raised there keep the system clock. `EventId` follows the same clock. A version 7 `Guid` is a timestamp plus random bits, so a fixed clock fixes the ordering and leaves the id unique. When you need the whole id to be predictable, supply the factory as well: ```csharp var next = 0; using var scope = DomainEventClock.Use(clock, _ => new Guid(++next, 0, 0, new byte[8])); ``` The values still have to be distinct. `EventId` is the primary key of the outbox table and the key handlers deduplicate on, so a repeated one makes the save fail. One sharp edge: a clock set before 1970 throws, because a version 7 `Guid` encodes Unix milliseconds and cannot represent an earlier instant. Start fake clocks at a realistic date. Reach for it when the time is what you are asserting on. When the time is something the domain reasons about, it belongs in the payload instead, where a rule can read it: ```csharp public sealed record OrderCancelled(OrderId OrderId, string Reason, DateTimeOffset CancelledAt) : DomainEvent; ``` Then pass the time in from your own clock abstraction and assert on `CancelledAt` like any other member. `EventId` and `OccurredAt` stay what they are: the identity and the wall-clock stamp of the occurrence, useful for idempotency and ordering, not for describing the domain. Outside tests, leave the clock alone. An event that reports a time other than the time it happened is a lie told to every consumer downstream. ## What this kit does not do **It does not control the clock, and it does not need to.** The assertions here never compare `EventId` or `OccurredAt`, so nothing in this package cares what they say. The hook for fixing them belongs to the core package; see [Deterministic time in tests](https://dylansnel.github.io/DDDToolkit/docs/testing.md#deterministic-time-in-tests). **It is not an event sourcing kit.** There is no `Given(events)` that rebuilds an aggregate from a stream, because these aggregates are not built that way. Arrange with the constructor and with real method calls. **It does not test persistence.** Nothing here touches a `DbContext`. Whether your events reach a handler, survive a rollback or arrive twice is a question about dispatch, not about the aggregate; see [Entity Framework](https://dylansnel.github.io/DDDToolkit/docs/entity-framework.md) for the interceptor, the outbox and the concurrency token, and test those against a real database. **It does not assert on your aggregate's state.** `Subject` hands you the aggregate and your own assertion library takes it from there. Two libraries fighting over one assertion style is worse than none. **It does not assert on invariants.** There is no `.Violates()`, because there does not need to be: both stages are public on every entity, so your own assertion library already covers them, as [Testing invariants](https://dylansnel.github.io/DDDToolkit/docs/testing.md#testing-invariants) shows. ## See also - [Domain events](https://dylansnel.github.io/DDDToolkit/docs/domain-events.md) for raising, draining and stable names. - [Invariants](https://dylansnel.github.io/DDDToolkit/docs/invariants.md) for the two stages, the two shapes of rule, and checking both without a database. - [Entities and aggregates](https://dylansnel.github.io/DDDToolkit/docs/entities-and-aggregates.md) for what an aggregate root is and why only the root raises events. - [Entity Framework](https://dylansnel.github.io/DDDToolkit/docs/entity-framework.md) for delivering the events you asserted on here. # Entity Framework `DDDToolkit.EntityFramework` is the persistence half of the toolkit. It teaches Entity Framework Core about the types the generators produce, so a domain model written the way the other pages describe maps to tables without hand-written configuration. It also carries the behaviours that need a save to hang off: domain event delivery, the invariant check and optimistic concurrency. The package does five things: | | What it gives you | |---|---| | Generated converters | A `ValueConverter` per identifier and single value object, and one registration call per assembly | | Conventions | Read-only collections mapped, `Version` made a concurrency token, `[Internal]` members ignored | | Domain event delivery | In-process dispatch during `SaveChanges`, or a transactional outbox | | Invariants at the save | Every entity a save adds or changes is asked for its invariants first; see [Invariants](https://dylansnel.github.io/DDDToolkit/docs/invariants.md#at-the-save) | | Optimistic concurrency | `Version` incremented per save, stale writes turned into `ConcurrencyConflictException` | It does not give you a repository abstraction or a message bus. `DbContext` is already the unit of work, and the delegate that hands events to your publisher is one you write. If your publisher is [Mediator](https://github.com/martinothamar/Mediator), the companion package `DDDToolkit.Mediator` writes that delegate for you; see [In-process dispatch](https://dylansnel.github.io/DDDToolkit/docs/event-delivery.md#in-process-dispatch). If the events have to leave the process, the outbox delivers to a sink you implement; see [Integration events](https://dylansnel.github.io/DDDToolkit/docs/integration-events.md). ## Install ```bash dotnet add package Temp.DDDToolkit.EntityFramework ``` The package brings its own source generator, so referencing it is all the configuration there is. It targets .NET 10 and Entity Framework Core 10. ## Wiring it up Three calls. One in your service registration, one on the `DbContextOptionsBuilder`, and one or more in `ConfigureConventions`. ```csharp using DDDToolkit.EntityFramework; builder.Services.AddDDDToolkitEntityFramework(); builder.Services.AddDbContext((services, options) => options .UseSqlite(connectionString) .UseDDDToolkit(services)); ``` ```csharp using DDDToolkit.EntityFramework.Conventions; using Ordering.Domain.Converters; public class OrderingContext(DbContextOptions options) : DbContext(options) { public DbSet Orders => Set(); protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) { configurationBuilder.AddDDDToolkitConventions(); configurationBuilder.AddOrderingConverters(); } } ``` That stores and loads aggregates. It does not yet save an aggregate that raised a domain event: with events pending, `SaveChanges` throws an `InvalidOperationException` that names the aggregate, says that no delivery mode is configured and names the two calls that would configure one. Dropping the events quietly would be worse, because each of them is a promise that something will happen. The argument to `AddDDDToolkitEntityFramework` says how events are delivered. Handing them to [Mediator](https://github.com/martinothamar/Mediator) handlers in the same process takes a second package, besides Mediator itself: ```bash dotnet add package Temp.DDDToolkit.Mediator ``` ```csharp using DDDToolkit.Mediator; builder.Services.AddMediator(options => options.ServiceLifetime = ServiceLifetime.Scoped); builder.Services.AddDDDToolkitEntityFramework(options => options.DispatchWithMediator()); ``` That package is optional, because delivery is a delegate you can write yourself against any library or none, and because the outbox is the other way to deliver. [Delivering domain events](https://dylansnel.github.io/DDDToolkit/docs/event-delivery.md) explains both and when to use which. ### `UseDDDToolkit` Adds the toolkit's interceptors to the context: the one that delivers domain events, the one that checks invariants and the one that raises the version. Pass the `IServiceProvider` that the `AddDbContext` callback gives you, not the root provider. That provider belongs to the same scope as the context, so a handler that injects `OrderingContext` receives the very instance that is saving. [The interceptors](https://dylansnel.github.io/DDDToolkit/docs/entity-framework.md#the-interceptors) lists them in the order they run. ### `AddDDDToolkitConventions` and `Add{Module}Converters` `AddDDDToolkitConventions` adds the four toolkit conventions to the model. It is the same for every context, so it takes no arguments. What each one does is under [What is generated and what is a convention](https://dylansnel.github.io/DDDToolkit/docs/entity-framework.md#what-is-generated-and-what-is-a-convention). `Add{Module}Converters` is generated, one per assembly that declares identifiers or single value objects. It registers the value converter the generator wrote for each of them, so Entity Framework stores an `OrderId` as the `Guid` inside it: ```csharp title="ConverterExtensions.g.cs, shortened" namespace Ordering.Domain.Converters; public static class ConverterExtensions { public static ModelConfigurationBuilder AddOrderingConverters(this ModelConfigurationBuilder modelConfigurationBuilder) { // ... modelConfigurationBuilder.Properties().HaveConversion(); modelConfigurationBuilder.DefaultTypeMapping().HasConversion(); // ... return modelConfigurationBuilder; } } ``` The method lives in a `Converters` namespace under the assembly name, in a static class called `ConverterExtensions`. Its name comes from the `DDD_Module` property that [Getting started](https://dylansnel.github.io/DDDToolkit/docs/getting-started.md#store-it-with-entity-framework) sets in the project file, so a project with `Ordering` gets `AddOrderingConverters`. Without it the generators use the assembly name with the dots removed. `DDD_Module` is only a name for generated methods. It is not what makes a project a module: that is `[assembly: Module("Ordering")]`, the boundary the analyzer checks, described in [Modules](https://dylansnel.github.io/DDDToolkit/docs/modules.md). [Why not the DDD_Module MSBuild property](https://dylansnel.github.io/DDDToolkit/docs/modules.md#why-not-the-ddd_module-msbuild-property) explains why the two are separate. Call one per assembly. A solution with a shared kernel and two modules calls three: ```csharp protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) { configurationBuilder.AddDDDToolkitConventions(); configurationBuilder.AddSharedKernelConverters(); configurationBuilder.AddOrderingConverters(); configurationBuilder.AddBillingConverters(); } ``` An assembly that declares no identifier and no single value object produces no method, because there would be nothing for it to register. ### `AddDDDToolkitEntityFramework` Registers the delivery configuration and the interceptors `UseDDDToolkit` adds. The argument configures delivery; see [Delivering domain events](https://dylansnel.github.io/DDDToolkit/docs/event-delivery.md). If your aggregates never raise events you can call it with no argument at all, but you still need it, because it is what registers the interceptors. It can be called more than once, which is what lets each module of a modular monolith register its own outbox next to its own context. See [An outbox per context](https://dylansnel.github.io/DDDToolkit/docs/event-delivery.md#an-outbox-per-context). ## Mapping Everything in this section works with no `OnModelCreating` code at all. The mapping tests in `Tests/DDDToolkit.EntityFramework.Tests/MappingTests.cs` cover each case against SQLite. ### Identifiers A struct identifier works as a primary key, as an ordinary property, and as a nullable property: ```csharp [EntityId("ORD")] public readonly partial record struct OrderId; [AggregateRoot] public partial class Order { public CustomerId Customer { get; private set; } public CourierId? Courier { get; private set; } } ``` The generator writes a converter into each identifier. It stores the underlying value, so the column is a `Guid`, an `int` or a `string`, not a serialized object: ```csharp title="OrderId.Converter.g.cs" readonly partial record struct OrderId { public sealed class OrderIdConverter : ValueConverter { public OrderIdConverter() : base(static v => v.Value, static v => new OrderId(v)) { } } } ``` and the identifier is usable in a query predicate: ```csharp var order = context.Orders.Single(o => o.Id == orderId); // the key var theirs = context.Orders.Count(o => o.Customer == customerId); // a plain property var unassigned = context.Orders.Count(o => o.Courier == null); // a nullable property ``` Class identifiers, declared as `partial record` rather than `readonly partial record struct`, map the same way and get a converter for their always-valid twin as well. ### Value objects A `[ValueObject]` record is annotated `[ComplexType]`, so its properties are stored inline in the owning table: ```csharp [ValueObject] public partial record PersonName(string FirstName, string LastName); ``` ```csharp title="PersonName.EntityFramework.g.cs" [ComplexType] partial record PersonName { } [ComplexType] partial record ValidPersonName { } ``` An order's `Recipient`, a `PersonName`, becomes `Recipient_FirstName` and `Recipient_LastName` columns on the `Orders` table, not a table of its own. A `[SingleValueObject]` is a converted scalar, so it becomes one column, and a nullable one stores and reads `null`. Its converter is written the same way as an identifier's, with a second one for the always-valid twin: ```csharp [SingleValueObject] public partial record EmailAddress { public static EmailAddress Create(string value) => new(value); } ``` ```csharp title="EmailAddress.Converter.g.cs" partial record EmailAddress { public sealed class EmailAddressConverter : ValueConverter { public EmailAddressConverter() : base(static v => v.Value, static v => new EmailAddress(v)) { } } } partial record ValidEmailAddress { public sealed class ValidEmailAddressConverter : ValueConverter { public ValidEmailAddressConverter() : base(static v => v.Value, static v => new ValidEmailAddress(v)) { } } } ``` See [Value objects](https://dylansnel.github.io/DDDToolkit/docs/value-objects.md#entity-framework). ### Child entities and owned collections `[Entity]` produces `[Owned]`, so a child entity is loaded and saved with its aggregate: ```csharp [Entity("LINE")] public partial class OrderLine { // Sku and Quantity } ``` ```csharp title="OrderLine.EntityFramework.g.cs" [Owned] partial class OrderLine { } ``` A generated `partial IReadOnlyList Lines { get; }` on the aggregate is discovered as an owned collection. The `[BackingField]` annotation on the generated property points Entity Framework at the private list, so it reads and writes the field and never tries to write through the read-only view: ```csharp title="Order.g.cs, shortened" partial class Order : AggregateRoot { protected Order() { } // ... private readonly List _lines = new(); private IReadOnlyList? __linesView; [BackingField(nameof(_lines))] public partial IReadOnlyList Lines => __linesView ??= _lines.AsReadOnly(); // ... } ``` The protected constructor is the one Entity Framework uses to materialize a row. The core generator writes `[BackingField]` only when the project references Entity Framework, so a domain project without it gets the same property without the annotation. Removing an element removes the row. Callers still cannot cast `Lines` back to `List` and mutate it. ### Composite keys A property marked `[KeyPart]` joins the primary key ahead of `Id`, and the foreign key of every owned child carries it too, so `Order` keyed `(RegionId, Id)` owns `OrderLine` rows keyed `(RegionId, OrderId, Id)`. The child needs a property of the same name; without one the model refuses to build. [Composite keys](https://dylansnel.github.io/DDDToolkit/docs/composite-keys.md) has the rules, the order of several parts, and how to override it. ### Read-only collections of primitives A generated `IReadOnlyList` where `T` is a primitive, a converted identifier or a single value object is mapped as an Entity Framework primitive collection, which on a relational provider is a JSON column. The elements are stored as their converted values: ```csharp [EntityId("TAG")] public readonly partial record struct TagId; public partial IReadOnlyList Tags { get; } ``` Two tags produce the column value `[1,2]`, not a pair of objects. This needs the convention. Entity Framework discovers primitive properties only when they have a setter, and a generated collection property is get-only, so without `AddDDDToolkitConventions` the property would be skipped silently and the data would never reach the database. The convention finds it, sets the element type, and copies the converter, max length, unicode, precision and scale from the type mapping the generated registration installed. The convention only considers public get-only properties. A `protected partial IReadOnlyList` is not mapped as a primitive collection. "A JSON column" is true on SQL Server and SQLite and not on PostgreSQL, which stores the same collection as a native array. LINQ queries port across that difference and hand-written SQL does not. See [Primitive collections do not port below LINQ](https://dylansnel.github.io/DDDToolkit/docs/entity-framework.md#primitive-collections-do-not-port-below-linq) before you write a report against one of these columns. ### What is not mappable Entity Framework Core 10 accepts only arrays and `IList` implementations as primitive collections. A generated `IReadOnlySet` is backed by a `HashSet`, so a set of primitives cannot be a primitive collection. Rather than leave the property silently unmapped, `ReadOnlyCollectionConvention` throws while the model is built. The message names the type and property, says that the backing field is a `HashSet` and that only arrays and `IList` qualify, and tells you to declare `IReadOnlyList` instead or exclude the property with `[NotMapped]` or `Ignore()`. You see it on first use of the context, not at save time. ```csharp public partial IReadOnlySet Keywords { get; } // throws while building the model public partial IReadOnlyList Keywords { get; } // maps ``` An `IReadOnlySet` of entities is fine. That is a navigation, not a primitive collection, so relationship discovery handles it and the children round-trip with their aggregate. ## Optimistic concurrency Every aggregate root carries `long Version`. It is 0 on a new instance, becomes 1 when the aggregate is inserted, and increases by exactly one per save that touches the aggregate. `AddDDDToolkitConventions` maps it as a concurrency token, so the `UPDATE` carries the loaded version in its `WHERE` clause. "Touches the aggregate" includes more than the root's own properties. `AggregateVersionInterceptor` walks from each changed entry to the aggregate root it belongs to, through ownership or through a foreign key whose principal is an aggregate root, and bumps that root. A change to an owned child, an element added to or removed from an owned collection, or a change to a primitive collection on a child, all version the root. Each root is bumped at most once per save, however many entries changed, and a save that changes nothing does not bump anything. Deleting an aggregate does not bump its version, but the token is still checked, so deleting an aggregate somebody else has changed in the meantime conflicts like any other stale write. The version is what makes the aggregate a unit of consistency: two people editing different lines of the same order still conflict, which is the point. When a save writes a stale version, you get a `ConcurrencyConflictException` naming the aggregate: ```csharp try { await context.SaveChangesAsync(cancellationToken); } catch (ConcurrencyConflictException conflict) { // conflict.AggregateType is typeof(Order), conflict.AggregateId is the OrderId. // Reload the aggregate, reapply the change and save again, or report it to the user. return Conflict(conflict.Message); } ``` There is no safe generic answer for the catch block, which is why the toolkit does not retry for you. Reloading and reapplying is right when the change is a command you can repeat, such as adding a line. Reporting the conflict is right when the user needs to see what changed underneath them. Blindly retrying a computed value is wrong. `conflict.InnerException` is the original `DbUpdateConcurrencyException` if you need the entries. [Why the exception is rethrown from `SaveChangesFailed`](https://dylansnel.github.io/DDDToolkit/docs/entity-framework.md#why-the-exception-is-rethrown-from-savechangesfailed) explains how it reaches your catch block unwrapped. ## Migrations There is nothing special to do. The toolkit adds types and configuration to your own `DbContext` model, so everything it maps appears in your ordinary migrations: ```bash dotnet ef migrations add AddOutbox dotnet ef database update ``` The outbox table is part of the model as soon as `AddDomainEventOutbox(Database)` is in `OnModelCreating`, so the next migration you scaffold contains it, `ddd` schema and all. Opting in is that one call. There is no separate package, no separate migration history table and no separate command. The same goes for `AddDomainEventInbox(Database)` on the consuming side. See [The table](https://dylansnel.github.io/DDDToolkit/docs/event-delivery.md#the-table). If you write migrations by hand rather than scaffolding them, `migrationBuilder.CreateDomainEventOutbox()` and its inbox and drop counterparts write the same tables. See [Integration events](https://dylansnel.github.io/DDDToolkit/docs/integration-events.md#tables-schema-and-migrations). The same applies to everything else on this page. Struct identifier columns, complex type columns, primitive collection columns and the `Version` column are all just columns in your model, so a `migrations add` after changing an aggregate produces the diff you would expect. If you map the outbox table before you need it, as the example context does, switching from in-process dispatch to the outbox later costs no migration at all. On Supabase the CLI applies migrations, not Entity Framework; see [Supabase](https://dylansnel.github.io/DDDToolkit/docs/entity-framework.md#supabase). ## What is generated and what is a convention The split is not arbitrary. A value converter is specific to one type, so it has to be written per type and registered per assembly, which is work for a generator. A convention is a rule about the shape of a model, identical in every context, so it lives in the runtime package. The generator emits, for each type: | You wrote | Generated | |---|---| | `[EntityId]` | A nested `ValueConverter`, for example `OrderId.OrderIdConverter`, storing `Value` | | `[SingleValueObject]` | The same, plus a converter for the always-valid twin | | `[ValueObject]` | `[ComplexType]` on the record and on its twin | | `[Entity]` | `[Owned]` on the class | and once per assembly, the registration method, which registers every converter twice: ```csharp title="ConverterExtensions.g.cs, shortened" modelConfigurationBuilder.Properties().HaveConversion(); modelConfigurationBuilder.DefaultTypeMapping().HasConversion(); ``` Both lines are needed and neither covers the other's ground. `Properties()` configures properties of that type, which is most of what a model contains. `DefaultTypeMapping()` configures the type where no property is involved: query parameters, constants, and the element type of a primitive collection. The read-only collection convention reads that default type mapping to find the converter for the elements of a generated `IReadOnlyList`, because Entity Framework does not carry property configuration down to collection elements. `ColumnLength` on an identifier or single value object becomes `HaveMaxLength` on the property registration and `HasMaxLength` on the type mapping. See [Identifiers](https://dylansnel.github.io/DDDToolkit/docs/identifiers.md#column-length). The conventions, added by `AddDDDToolkitConventions`, are: | Convention | What it does | |---|---| | `InternalMemberConvention` | Ignores every member carrying `[Internal]`, on entity types and complex types alike | | `ReadOnlyCollectionConvention` | Maps generated get-only collections of primitives and converted types as primitive collections, applying the element converter | | `AggregateRootVersionConvention` | Makes `Version` on every aggregate root a concurrency token | | `KeyPartConvention` | Puts `[KeyPart]` properties into the primary key ahead of `Id`, and into the foreign key of every owned type below; see [Composite keys](https://dylansnel.github.io/DDDToolkit/docs/composite-keys.md) | `InternalMemberConvention` is why `DomainEvents` never reaches your tables. The `AggregateRoot` base class marks it `[Internal]`, so the convention ignores it exactly as `[NotMapped]` would. Explicit configuration in `OnModelCreating` still wins over any of this. The conventions fill in what you did not say. ## The interceptors `UseDDDToolkit` adds three interceptors, in the order they run: | | Interceptor | Why there | |---|---|---| | 1 | `PublishDomainEventsInterceptor` | Delivers domain events first, so whatever the handlers change is part of the same save | | 2 | `InvariantInterceptor` | Sees whatever those handlers changed | | 3 | `AggregateVersionInterceptor` | Comes last, so a save the invariants reject leaves no version bumped | `AddDDDToolkitEntityFramework` registers them. The options object is a singleton, `PublishDomainEventsInterceptor` is scoped so that it can hand handlers the scope that owns the `DbContext` being saved, and `InvariantInterceptor` and `AggregateVersionInterceptor` are singletons because they hold no state. ## Why the exception is rethrown from `SaveChangesFailed` The interceptor translates the conflict in `ThrowingConcurrencyException`, but the update pipeline wraps whatever that throws in a `DbUpdateException`, so callers would have to dig through inner exceptions to find it. The interceptor therefore also handles `SaveChangesFailed`, unwraps the `ConcurrencyConflictException` and rethrows it, which is what lets you write `catch (ConcurrencyConflictException)` directly. It does the same for a provider that raised the conflict without passing through `ThrowingConcurrencyException`. Both the synchronous and the asynchronous save path behave the same. `Tests/DDDToolkit.EntityFramework.Tests/ConcurrencyTests.cs` covers the version arithmetic, child-only changes, deletes and both paths. ## Providers Everything on this page is tested on SQLite, PostgreSQL and SQL Server. SQLite is where the fast suite runs; the other two run in containers, in their own CI workflow, against the same aggregates and the same context. Most of the mapping is identical on all three. This section is the part that is not, so that none of it is a surprise in production. ### What differs, and what to do about it | | SQLite | PostgreSQL | SQL Server | |---|---|---|---| | `Guid` identifier | `TEXT` | `uuid` | `uniqueidentifier` | | Outbox timestamps | UTC `DateTime` | `timestamptz` | `datetimeoffset` | | Primitive collection | JSON `TEXT` | `integer[]` | JSON `nvarchar` | | Schemas | ignored | yes | yes | Timestamps are covered under [Timestamps](https://dylansnel.github.io/DDDToolkit/docs/event-delivery.md#timestamps). Schemas are ignored by SQLite, which drops the schema when it writes an identifier, so `ddd.OutboxMessages` is plain `OutboxMessages` there and nothing else changes. The primitive collection is the one that needs a paragraph. ### Primitive collections do not port below LINQ A generated `IReadOnlyList` is mapped as an Entity Framework primitive collection. What that becomes in the database is the provider's decision, not the toolkit's, and the providers decide differently: - **PostgreSQL** stores it as a real `integer[]`. Npgsql maps .NET collections of a primitive onto PostgreSQL's own array types, which is the better mapping and the reason it does it. - **SQL Server and SQLite** store it as a JSON document in a string column, `[1,2]`. **This is inherent to Entity Framework Core, not something the toolkit chose or can sensibly undo.** The mapping is supplied by each provider's type mapping source. The toolkit's convention only finds the get-only property, sets its element type and copies the element converter across; the store type is chosen after that, by the provider, exactly as it would be for a hand-written `modelBuilder.PrimitiveCollection(...)`. Forcing every provider onto the same store type would mean overriding Npgsql's native array mapping with a worse one for the sake of a portability nobody asked for, and would break every PostgreSQL index and query already written against the array. So the practical rule is: **LINQ ports.** `Contains`, `Count` and the rest translate on both providers, to `= ANY(...)` on PostgreSQL and to `OPENJSON` on SQL Server. If your query goes through `IQueryable`, you can move providers and it keeps working. ```csharp // Translates on both. var tagged = await context.Shelves .SelectMany(shelf => shelf.Books) .Where(book => book.Tags.Contains(new TagId(7))) .ToListAsync(); ``` **SQL does not port.** Anything that reaches inside the column in hand-written SQL is provider specific, and there is no spelling that runs on both: ```sql -- PostgreSQL SELECT COUNT(*) FROM "Book" WHERE 7 = ANY("Tags"); -- SQL Server SELECT COUNT(*) FROM "Book" WHERE EXISTS (SELECT 1 FROM OPENJSON("Tags") WHERE CAST([value] AS int) = 7); ``` That matters for reports, ad hoc queries, data migrations and anything a DBA writes. Indexing differs too: a PostgreSQL array takes a GIN index, a JSON string column does not. If you need a query like that to port, the answer is not to fight the mapping. Model the collection as a child entity with its own table, which is one row per tag and identical on every provider. You lose the single-column read and gain a join. That is a design decision, so make it because you need the portability, not by accident. Both halves are asserted against real servers in `Tests/DDDToolkit.EntityFramework.Providers.Tests/Providers/ProviderMappingTests.cs`. ## Domain event delivery A raised domain event is a promise that something will happen, and this package keeps it when the context saves: by running your handlers inside `SaveChanges`, or by writing the events to an outbox table in the same transaction and delivering them afterwards. [Delivering domain events](https://dylansnel.github.io/DDDToolkit/docs/event-delivery.md) explains both modes, how to choose between them, and the outbox's table, processor and retries. ## Supabase The Supabase CLI applies migrations from SQL files in `supabase/migrations`, not from Entity Framework. `DDDToolkit.EntityFramework.Supabase` writes one such file per Entity Framework migration as part of the build, and can check at start-up that Supabase applied them. See [Supabase](https://dylansnel.github.io/DDDToolkit/docs/supabase.md). ## Where to look next - [Getting started](https://dylansnel.github.io/DDDToolkit/docs/getting-started.md) for the module name and your first aggregate. - [Delivering domain events](https://dylansnel.github.io/DDDToolkit/docs/event-delivery.md) for in-process dispatch, Mediator and the outbox. - [Identifiers](https://dylansnel.github.io/DDDToolkit/docs/identifiers.md) for what a struct identifier actually is, and `ColumnLength`. - [Value objects](https://dylansnel.github.io/DDDToolkit/docs/value-objects.md) for `[ValueObject]` and `[SingleValueObject]`. - [Entities and aggregates](https://dylansnel.github.io/DDDToolkit/docs/entities-and-aggregates.md) for read-only collections and `[BackingField]`. - [Invariants](https://dylansnel.github.io/DDDToolkit/docs/invariants.md) for the check every save runs. - [Domain events](https://dylansnel.github.io/DDDToolkit/docs/domain-events.md) for raising, draining and stable names. - [Integration events](https://dylansnel.github.io/DDDToolkit/docs/integration-events.md) for sinks, published contracts and the inbox. - [Supabase](https://dylansnel.github.io/DDDToolkit/docs/supabase.md) for exporting migrations to the Supabase CLI. - [Diagnostics](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md) for the build errors the generators report. The runnable version of everything here is the shop in [`Examples/ModularMonolith.Supabase`](https://github.com/DylanSnel/DDDToolkit/tree/main/Examples/ModularMonolith.Supabase). The host's [`Program.cs`](https://github.com/DylanSnel/DDDToolkit/blob/main/Examples/ModularMonolith.Supabase/DDDToolkit.Examples.Host/Program.cs) shows the registration, [`OrderingContext.cs`](https://github.com/DylanSnel/DDDToolkit/blob/main/Examples/Modules/Ordering/DDDToolkit.Examples.Ordering/Infrastructure/Persistence/OrderingContext.cs) shows the conventions and the generated converters, [`OrderingModule.cs`](https://github.com/DylanSnel/DDDToolkit/blob/main/Examples/Modules/Ordering/DDDToolkit.Examples.Ordering/OrderingModule.cs) shows the outbox, and [`OrderingEndpoints.cs`](https://github.com/DylanSnel/DDDToolkit/blob/main/Examples/Modules/Ordering/DDDToolkit.Examples.Ordering/Api/OrderingEndpoints.cs) shows the conflict catch block. The example's `supabase/` folder and each module's `Migrations` folder show the Supabase export. ## For contributors: running the provider tests ```bash dotnet test Tests/DDDToolkit.EntityFramework.Providers.Tests --filter "Provider=Postgres" dotnet test Tests/DDDToolkit.EntityFramework.Providers.Tests --filter "Provider=SqlServer" ``` They need Docker. Without it they skip themselves, naming what could not be started, because a machine without Docker is a normal machine and should not go red. That skip is a problem in CI, where a green run would then say PostgreSQL and SQL Server pass without either having started. Setting `DDDTOOLKIT_REQUIRE_CONTAINERS=1` turns every such skip into a failure that says what was missing. Both workflows set it, and the provider workflow also counts the tests that ran, so a filter that matched nothing cannot pass either. The images are pinned exactly, in `Tests/DDDToolkit.EntityFramework.Providers.Tests/Infrastructure/ContainerImages.cs`, with the reason for each next to it. No floating tag, so a red build is always something we changed. PostgreSQL matches the major of the pgmq image the Postgres package is tested against; SQL Server is the older release still in mainstream support, which is the weaker of the two and therefore the one worth testing. # Composite keys Some tables are keyed on more than the aggregate's identifier. A schema partitioned by region keys every table `(region_id, id)`, and the foreign key of an owned child carries `region_id` as well, so the database itself refuses a child that points at a parent in another region. Ledgers, periods and tenants have the same shape. `[KeyPart]` maps that shape. It is one attribute and one Entity Framework convention, and it is all the toolkit does: it builds the key. What the key part *means*, where its value comes from and who may see which rows are your application's business. This page is about Entity Framework only. The key is built by `KeyPartConvention`, which `AddDDDToolkitConventions()` from `DDDToolkit.EntityFramework` adds to the model. Without that package the attribute changes nothing about how an entity behaves. ```csharp [AggregateRoot] public partial class Project { public Project(RegionId regionId, ProjectId id, string name) : base(id) { RegionId = regionId; Name = name; } [KeyPart] public RegionId RegionId { get; } public string Name { get; private set; } public partial IReadOnlyList Milestones { get; } public void Plan(string title) => _milestones.Add(new Milestone(RegionId, MilestoneId.CreateUnique(), title)); } [Entity] public partial class Milestone { public Milestone(RegionId regionId, MilestoneId id, string title) : base(id) { RegionId = regionId; Title = title; } [KeyPart] public RegionId RegionId { get; } public string Title { get; private set; } } ``` The generator writes one thing for a key part, in the entity's own generated part: the names of its key parts, in declaration order, behind the `IHasKeyParts` interface. That list is what the convention reads. ```csharp title="Project.g.cs, shortened" partial class Project : AggregateRoot, IHasKeyParts { // ... static IReadOnlyList IHasKeyParts.KeyParts => new string[] { nameof(RegionId) }; // ... } ``` `Milestone` gets the same list. The Entity Framework generator writes nothing for a key part: `Milestone`'s Entity Framework part is the `[Owned]` every child entity gets, and nothing more. There is no generated `HasKey` or `HasForeignKey`; the key is decided by the convention while Entity Framework builds the model. With `AddDDDToolkitConventions()` in `ConfigureConventions` and nothing in `OnModelCreating`, the example maps to: ```sql CREATE TABLE "Projects" ( "Id" uuid NOT NULL, "RegionId" uuid NOT NULL, "Name" text NOT NULL, "Version" bigint NOT NULL, CONSTRAINT "PK_Projects" PRIMARY KEY ("RegionId", "Id") ); CREATE TABLE "Milestone" ( "Id" uuid NOT NULL, "RegionId" uuid NOT NULL, "ProjectId" uuid NOT NULL, "Title" text NOT NULL, CONSTRAINT "PK_Milestone" PRIMARY KEY ("RegionId", "ProjectId", "Id"), CONSTRAINT "FK_Milestone_Projects_RegionId_ProjectId" FOREIGN KEY ("RegionId", "ProjectId") REFERENCES "Projects" ("RegionId", "Id") ON DELETE CASCADE ); ``` ## What it is not `[KeyPart]` builds a key. It does not fill the value in, filter queries by it, or check that the caller may see a row. If you need those, they are application concerns: an interceptor or the constructor to set the value, a global query filter or the database's own row-level security to restrict what is read. The toolkit stays out of that on purpose, so the attribute means the same thing in every application that uses it. Everything else about an entity is unchanged. Reference other aggregates by id as usual. A composite key does not change what another aggregate holds: `CustomerId` is still just a `CustomerId`, and a query that needs the other aggregate's region supplies it. ## The rules **A key part joins the primary key ahead of the identifier.** `Project` is keyed `(RegionId, Id)`. **Every owned relationship carries it.** The foreign key from `Milestone` to `Project` is `(RegionId, ProjectId)`, and `Milestone`'s own key is `(RegionId, ProjectId, Id)`. The `RegionId` in that foreign key is the child's own `RegionId` property, not a hidden copy, so there is one column and a child row can only ever sit under a parent in its own region. This applies to owned collections, to single owned entities (which live in the owner's row and share its `RegionId` column) and to anything owned further down. Because the child's property *is* the foreign key, Entity Framework treats it as one: when it saves an owned child it fills the foreign key in from the owner, and a child constructed with a different region is stored, and read back, with its owner's. Nothing throws. Set the child's value from the parent, as `Plan` does above, and the question does not come up. **The child must have the property.** An owned type needs a property with the same name and type as each of its owner's key parts. Leave it out and the model refuses to build, with an exception that names the owner, the child and the property. That happens the first time the context's model is built, before any query, which is on purpose: the alternative is a narrower key than the one you asked for, discovered in production. Mark the child's property `[KeyPart]` too. It is not required for the foreign key, but it gets the child the same public-setter check as its parent. **With more than one, declaration order.** The key follows the order the properties are declared in the source, top to bottom: ```csharp [KeyPart] public int Period { get; } // first [KeyPart] public RegionId RegionId { get; } // second // key: (Period, RegionId, Id) ``` Not alphabetical order, and not whatever order reflection happens to return. The generator reads the order from the source and writes it down in the class, in the `KeyParts` list shown above (`nameof(Period), nameof(RegionId)` for these two), where the convention reads it back. That is also why all key parts of a class must be declared in one file ([DDD00030](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00030)): between the files of a partial class there is no declaration order to follow. **It is an ordinary property.** You set it, usually through the constructor, and the toolkit never assigns, validates or interprets it. Declare it get-only, `{ get; }`, or with a private setter. Entity Framework does not map get-only properties by convention; the key-part convention maps them itself. A public setter reports [DDD00029](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00029), because a key value that can be reassigned after the row exists is a bug waiting to happen. **It is not identity.** Two instances with the same `Id` are equal, whatever their key parts hold, exactly as `Version` plays no part in equality. Entity Framework's change tracker uses the whole key, so it will happily track both; the domain treats them as the same entity. ## Explicit configuration wins Anything you configure yourself is left alone. A key set in `OnModelCreating` with `HasKey`, or with `[PrimaryKey]` on the class, stays as written, and the convention then leaves that type's owned types alone as well. So does an ownership whose foreign key you set with `HasForeignKey`. If the convention does something you do not want, configure that entity yourself and it steps aside: ```csharp modelBuilder.Entity().HasKey(project => project.Id); // back to (Id), key part or not ``` ## Limits - **Owned entities only.** The foreign keys the convention rewrites are ownerships, which is how `[Entity]` children are mapped. A relationship you configure between two aggregate roots is yours to key. - **An owned entity inside an owned entity** is not discovered by Entity Framework on its own, key parts or not. Declare the nesting with `OwnsMany`/`OwnsOne` and the convention keys it as usual: ```csharp modelBuilder.Entity().OwnsMany(p => p.Milestones, m => m.OwnsMany(x => x.Tasks)); ``` The inner type's foreign key is then `(RegionId, MilestoneProjectId, MilestoneId)`. - **Changing a key is a migration.** Adding `[KeyPart]` to an existing aggregate changes its primary key and every owned table's foreign key. Entity Framework generates the migration, but rebuilding a primary key on a large table is not something to find out about from `dotnet ef migrations add`. ## What is not touched A model with no `[KeyPart]` anywhere is not changed in any way: the convention looks for key parts first and returns without touching the model when there are none. In a model that has some, every type that neither declares key parts nor is owned by one that does is mapped exactly as it would be without the convention. Both are covered by tests that compare the whole model with and without it. The generator adds nothing to a class without key parts either. Only a class with them implements `DDDToolkit.Interfaces.IHasKeyParts`, which lists the parts in declaration order for the convention. You never implement that interface by hand. # Delivering domain events A raised domain event is a promise that something will happen. An order was placed, so a confirmation goes out, stock is reserved and a van is booked. Raising the event only puts it on the aggregate; nothing has happened yet. The question this page answers is when the promised thing does happen: inside the save, in the same transaction as the aggregate, or after it, from a record that survives a crash. `DDDToolkit.EntityFramework` offers one mode for each answer. In-process dispatch runs your handlers while `SaveChanges` runs. The outbox writes the events to a table in the same transaction and delivers them afterwards. Configure exactly one for production. If you configure neither, an aggregate that raised events refuses to save rather than lose them; see [When no delivery mode is configured](https://dylansnel.github.io/DDDToolkit/docs/event-delivery.md#when-no-delivery-mode-is-configured). Both modes keep the event inside this process. To send it somewhere else, add a sink to the outbox: that is a third destination and a separate page, [Integration events](https://dylansnel.github.io/DDDToolkit/docs/integration-events.md). This page assumes a context wired up as in [Entity Framework](https://dylansnel.github.io/DDDToolkit/docs/entity-framework.md#wiring-it-up). ## Choosing In process, the handlers run inside the save. Whatever they change on the same context is written with the aggregate, in one transaction, and a handler that throws stops the save: ```mermaid sequenceDiagram participant Code as Your code participant Order as Order participant Save as SaveChanges participant Handlers as Your handlers participant Db as Database Code->>Order: order.Cancel(reason) Order->>Order: RaiseDomainEvent(new OrderCancelled(...)) Note over Order: the event waits on the aggregate Code->>Save: SaveChangesAsync() Save->>Order: take the pending events Save->>Handlers: dispatch them, in the order raised Handlers-->>Save: changes to other tracked entities Note over Save,Handlers: events the handlers raise: another round Save->>Save: check the invariants, raise Version Save->>Db: one transaction: the order and the handlers' changes ```
Show the code: dispatching in process Register the toolkit with Mediator as the dispatcher, and add the toolkit to the context: ```csharp builder.Services.AddMediator(options => options.ServiceLifetime = ServiceLifetime.Scoped); builder.Services.AddDDDToolkitEntityFramework(options => options.DispatchWithMediator()); builder.Services.AddDbContext((services, options) => options .UseNpgsql(connectionString) .UseDDDToolkit(services)); ``` The event is a Mediator notification, and a handler is an ordinary Mediator handler. It runs in the saving context's scope, so what it changes on that context is saved with the order: ```csharp [DomainEventName("ordering.order-cancelled")] public sealed record OrderCancelled(OrderId OrderId, string Reason) : DomainEvent, INotification; public sealed class OrderLog(ILogger logger) : INotificationHandler { public ValueTask Handle(OrderCancelled notification, CancellationToken cancellationToken) { logger.LogInformation("Order {OrderId} was cancelled: {Reason}", notification.OrderId, notification.Reason); return default; } } ``` *[`Ordering/Application/Orders/DomainEvents/OrderLog.cs`](https://github.com/DylanSnel/DDDToolkit/blob/main/Examples/Modules/Ordering/DDDToolkit.Examples.Ordering/Application/Orders/DomainEvents/OrderLog.cs)* See [In-process dispatch](https://dylansnel.github.io/DDDToolkit/docs/event-delivery.md#in-process-dispatch).
Through the outbox, the save only writes the events down, next to the aggregate. Delivering them is a separate step that comes after the commit, and keeps trying until it succeeds: ```mermaid sequenceDiagram participant Code as Your code participant Save as SaveChanges participant Db as Database participant Processor as Outbox processor participant Target as Handlers or sinks Code->>Save: SaveChangesAsync() Save->>Db: one transaction: the order and one outbox row per event Note over Code,Db: committed, and nothing has been delivered yet loop every polling interval Processor->>Db: rows not yet processed, oldest first Processor->>Target: deliver each event alt delivered Processor->>Db: set ProcessedAt else it failed Processor->>Db: Attempts + 1 and LastError, try again later end end ```
Show the code: the outbox Map the outbox table in the context: ```csharp protected override void OnModelCreating(ModelBuilder modelBuilder) => modelBuilder.AddDomainEventOutbox(Database); ``` Turn the outbox on, and run the processor that delivers from it: ```csharp builder.Services.AddDDDToolkitEntityFramework(options => { options.DispatchWithMediator(); // where the processor delivers to options.UseOutbox(outbox => outbox.RegisterEventsFromAssemblyContaining()); }); builder.Services.AddOutboxBackgroundService(TimeSpan.FromSeconds(2)); ``` The handlers are the same as in process. They must be idempotent, because a crash between delivering and marking the row delivers it again. See [The outbox](https://dylansnel.github.io/DDDToolkit/docs/event-delivery.md#the-outbox).
| | In-process | Outbox | |---|---|---| | When handlers run | Inside `SaveChanges`, before the write | After the commit, on the processor | | Survives a crash | No | Yes | | Handler failure | Aborts the save | Recorded on the row, retried | | Delivery | Best-effort, at most once | At-least-once | | Handlers must be idempotent | No | Yes | | Extra table | No | Yes | | Extra moving part | No | A processor or background service | Use in-process dispatch for side effects inside the same database, where the transaction is the guarantee you want. Use the outbox for anything that leaves the process, and give it a sink to leave through. Nothing stops you from mapping the outbox table from the start and switching later. The example context does exactly that, so moving from one mode to the other is a change in the registration with no schema change. ## In-process dispatch With `DispatchInProcess` and no outbox, handlers run inside `SaveChanges`, before the database is written. Whichever way you hand the events to your handlers, that gives you three things: - Anything a handler changes on the same `DbContext` rides the same save, and therefore the same transaction. The interceptor calls `DetectChanges` after each round so those changes are seen. - A throwing handler aborts the save. Nothing is written. - New events raised by handlers on tracked aggregates are dispatched in a further round. What you do not get is durability. Delivery is best-effort. The events are dequeued from the aggregate before dispatch, so once a handler has them they exist only in memory: if the save then fails, the events are gone. Nothing survives a process crash. And a handler that talks to the outside world has already sent the mail or made the HTTP call by the time a later failure rolls the transaction back. Do not call `SaveChanges` from a handler in this mode. The save is already in progress and it will pick your changes up. The events reach your handlers through a delegate. `DDDToolkit.Mediator` writes it for you; you can also write it yourself. ### The short way: `DispatchWithMediator()` `DDDToolkit.Mediator` writes the delegate for you, against [Mediator](https://github.com/martinothamar/Mediator): ```bash dotnet add package Temp.DDDToolkit.Mediator ``` ```csharp using DDDToolkit.Mediator; builder.Services.AddMediator(options => options.ServiceLifetime = ServiceLifetime.Scoped); builder.Services.AddDDDToolkitEntityFramework(options => options.DispatchWithMediator()); ``` It resolves `IPublisher` from the scope that owns the saving `DbContext` and publishes each event in the order it was raised, awaiting one before starting the next. That is the delegate below plus a check that each event is publishable at all, so it serves both delivery modes: add `UseOutbox` and the processor delivers through the same call. Three things are worth knowing before you reach for it. **Your events have to implement `Mediator.INotification`.** Mediator cannot publish anything else. One marker interface for the whole solution is the usual way to say it once: ```csharp public interface IOrderingEvent : IDomainEvent, INotification; ``` An event that does not implement it makes the dispatch throw, naming the event type. It is not skipped. By the time the delegate runs the interceptor has already dequeued the event from the aggregate, so skipping would destroy it with no row, no log and nothing to retry. **Register Mediator as scoped when handlers touch the `DbContext`.** Mediator registers every handler as a singleton by default, and a singleton cannot depend on a scoped service. The lifetime is read off your `AddMediator` call at compile time, so it has to be written there; setting it any other way throws at start-up. **Keep `Mediator.SourceGenerator` in your composition root.** Mediator generates its implementation and `AddMediator` into whichever assembly the generator runs in, so reference the generator from the project that builds the container and from nowhere else. Handlers in other projects are still discovered, as long as those projects are referenced. `DDDToolkit.Mediator` itself references only `Mediator.Abstractions`, so it adds no generator to your domain projects. Mediator also reports `MSG0005` at build time for a notification that no handler handles. That is usually the mistake it looks like, but an event you deliberately leave unhandled needs the warning suppressed. ### The delegate `DispatchWithMediator()` is a convenience. The toolkit core has no mediator dependency and is not getting one: in-process delivery is a delegate, and you can write it against any library or none. Both modes deliver through the same delegate, so handlers are written once: ```csharp Func, CancellationToken, Task> ``` The provider is the scope that owns the saving `DbContext`, and the events arrive in the order they were raised. Written against Mediator's `IPublisher`, it looks like this: ```csharp builder.Services.AddDDDToolkitEntityFramework(options => { options.DispatchInProcess(async (services, events, cancellationToken) => { var publisher = services.GetRequiredService(); foreach (var domainEvent in events) { await publisher.Publish(domainEvent, cancellationToken); } }); }); ``` There is one delegate per process. A second `DispatchInProcess` or `DispatchWithMediator()` throws rather than replace the first. ## The outbox With `UseOutbox`, nothing is dispatched at save time. Instead one row per event is added to the saving context, so the events commit atomically with the aggregate, and a separate processor delivers them afterwards through the same dispatch delegate. ```csharp builder.Services.AddDDDToolkitEntityFramework(options => { options.DispatchWithMediator(); // or your own DispatchInProcess(...) delegate options.UseOutbox(outbox => outbox.RegisterEventsFromAssemblyContaining()); }); builder.Services.AddOutboxBackgroundService(TimeSpan.FromSeconds(2)); ``` The rows need a table in the context's model: `modelBuilder.AddDomainEventOutbox(Database)` in `OnModelCreating`, described under [The table](https://dylansnel.github.io/DDDToolkit/docs/event-delivery.md#the-table). A save with an outbox configured and no table mapped throws, naming the context. An event is never lost and never published for a transaction that rolled back. Rolling back the aggregate rolls back its events, because they are rows in the same transaction. The cost is at-least-once delivery. A message is marked processed only after its handlers returned, so a crash in between redelivers it. Handlers must be idempotent, keyed on `EventId`. Written this way the outbox is durable but still in-process: the processor hands the event back to the same delegate. Add a sink and the row leaves the process instead: ```csharp options.UseOutbox(outbox => { outbox.RegisterEventsFromAssemblyContaining(); outbox.SendTo(); }); ``` `outbox.AlsoDispatchInProcess = true` asks for both; see [Which delivery wins](https://dylansnel.github.io/DDDToolkit/docs/integration-events.md#which-delivery-wins). Sinks, the published contract that is not your domain event, and the inbox that makes at-least-once delivery safe to consume are all on [Integration events](https://dylansnel.github.io/DDDToolkit/docs/integration-events.md). ## The outbox in detail ### The table Map it in `OnModelCreating`: ```csharp protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.AddDomainEventOutbox(Database); } ``` `Database` is the context's own property. The method reads one thing from it, the provider name, which is what lets the timestamp columns be the right shape for the database you are actually on. See [Timestamps](https://dylansnel.github.io/DDDToolkit/docs/event-delivery.md#timestamps) below. The table is `ddd.OutboxMessages` by default. The method takes `tableName` and `schema` if you want something else, and `schema: null` puts it in the provider's default schema. SQLite has no schemas and ignores the argument, so the table is plain `OutboxMessages` there. The mapping sets a primary key on `Id`, an index on `ProcessedAt`, and the lengths below. | Column | Type | Meaning | |---|---|---| | `Id` | `Guid`, key, never generated | The event's `EventId`. This is the idempotency key | | `EventName` | `string`, required, 256 | The stable name, from `[DomainEventName]` or the convention, `ordering.order-placed` | | `Payload` | `string`, required | The event serialized with System.Text.Json | | `Version` | `int` | The shape the payload was written in, 1 unless the event type says otherwise; see [The outbox reading its own old rows](https://dylansnel.github.io/DDDToolkit/docs/integration-events.md#the-outbox-reading-its-own-old-rows) | | `OccurredAt` | `DateTimeOffset` | Taken from the event | | `AggregateType` | `string?`, 512 | CLR type name of the aggregate that raised it | | `AggregateId` | `string?`, 256 | The aggregate's key as text, parts joined with `\|` | | `CreatedAt` | `DateTimeOffset` | When the row was written | | `ProcessedAt` | `DateTimeOffset?` | When the handlers succeeded, `null` while pending | | `Attempts` | `int` | How often delivery was attempted | | `NextAttemptAt` | `DateTimeOffset?` | When a failed message may be tried again, `null` when it is due now; see [Failures, retries and poison messages](https://dylansnel.github.io/DDDToolkit/docs/event-delivery.md#failures-retries-and-poison-messages) | | `LastError` | `string?`, 4000 | Type and message of the last failure | `CreatedAt` comes from `options.TimeProvider`, which defaults to `TimeProvider.System` and can be replaced in tests. So does the current time the processor compares `NextAttemptAt` with. The index is on `ProcessedAt` alone, because that is what separates the few pending rows from the many delivered ones. `NextAttemptAt` is not in it. It only divides the pending rows into due and waiting, and "`null` or not after now" is not one range an index could seek, so adding it would make the index bigger without making the poll faster. Payloads are written with System.Text.Json through `outbox.JsonOptions`, which by default are case-insensitive on read and carry the toolkit's `SingleValueObjectConverterFactory`, so identifiers and single value objects are stored as their raw values rather than as objects. The same options read the payload back, so change them with care once messages exist. ### Timestamps `OccurredAt`, `CreatedAt`, `ProcessedAt` and `NextAttemptAt` are `DateTimeOffset` in the model. What they become in the database depends on the provider, and you can say otherwise: ```csharp // The provider's own instant type. modelBuilder.AddDomainEventOutbox(Database); // A UTC DateTime column, on every provider. modelBuilder.AddDomainEventOutbox(Database, timestamps: DomainEventTimestamps.UtcDateTime); ``` | Provider | `ProviderDefault` | `UtcDateTime` | |---|---|---| | PostgreSQL | `timestamp with time zone` | `timestamp with time zone` | | SQL Server | `datetimeoffset` | `datetime2` | | SQLite | UTC `DateTime` as text | UTC `DateTime` as text | The one thing the outbox needs from these columns is that they sort as instants, because the processor reads pending messages oldest first and compares `NextAttemptAt` with the current time. SQLite stores a `DateTimeOffset` as text and refuses to order by one or compare one, so on SQLite the toolkit converts to a UTC `DateTime` whatever you ask for. Every other provider has an instant type that orders correctly, and gets it. Whichever column it lands in, the value is normalized to UTC on the way in. Write a `DateTimeOffset` that carries `+02:00` and the row holds the same instant with an offset of zero, and reads back that way. That makes the two shapes interchangeable in meaning, and it is also what keeps Npgsql happy: PostgreSQL refuses a `DateTimeOffset` whose offset is not zero. `AddDomainEventInbox` takes the same argument for its own `ProcessedAt`. Pass both the same thing. If you write migrations by hand, `CreateDomainEventOutbox` and `CreateDomainEventInbox` take `timestamps` too and default to the same `ProviderDefault`. They read `MigrationBuilder.ActiveProvider`, so the hand-written table and the scaffolded one agree. A database created by an earlier 3.0 build may have the older column type on SQL Server; see [From an earlier 3.0 build](https://dylansnel.github.io/DDDToolkit/docs/migrating-to-3.md#from-an-earlier-30-build) before upgrading one. ### Registering event types The row stores a name, so the processor needs a name-to-type map: ```csharp options.UseOutbox(outbox => { outbox.RegisterEventsFromAssemblyContaining(); outbox.RegisterEventsFromAssembly(typeof(OrderPlaced).Assembly); outbox.RegisterEvent(); }); ``` The three calls are additive, so use whichever suits; most applications need only the first. The assembly scans register every concrete type implementing `IDomainEvent`. Registering two types under the same stable name throws an `ArgumentException` naming both, which is the failure you want at start-up rather than at delivery time. This is where a stable name earns its keep. The name is written into the row and read back later, so it must not change while rows are waiting. The conventional name, the module and the class name in kebab case, does not change when the class moves; when the class is renamed, `[DomainEventName]` keeps the old one. See [Stable names](https://dylansnel.github.io/DDDToolkit/docs/domain-events.md#stable-names). The compiler can write this registration for you. With the Entity Framework package referenced, every assembly gets an `Add{Module}IntegrationEvents()` that registers each of its domain events under the name it is stored as, worked out at compile time, so nothing is scanned at start-up: ```csharp [assembly: Module("Ordering")] [DomainEventName("ordering.order-received")] // renamed from OrderReceived; rows keep the old name public sealed record OrderPlaced(OrderId Order) : DomainEvent; public sealed record OrderCancelled(OrderId Order) : DomainEvent; ``` ```csharp title="IntegrationEventExtensions.g.cs, shortened" public static OutboxOptions AddOrderingIntegrationEvents(this OutboxOptions outbox) { ArgumentNullException.ThrowIfNull(outbox); outbox.RegisterEvent("ordering.order-cancelled", 1); outbox.RegisterEvent("ordering.order-received", 1); return outbox; } ``` `OrderPlaced` goes into the row under its pinned name and `OrderCancelled` under the conventional one. Call `outbox.AddOrderingIntegrationEvents()` in place of the assembly scan. The same method registers what a module publishes, which [Integration events](https://dylansnel.github.io/DDDToolkit/docs/integration-events.md#registered-when-the-module-compiles) covers. An event name nobody registered is not fatal. The processor records the failure on the row, with a `LastError` that names the missing event and the registration call, increments `Attempts`, leaves `ProcessedAt` null and carries on with the rest of the batch. Register the type and the next attempt delivers the row. ### Running the processor For a hosted application, the background service: ```csharp builder.Services.AddOutboxBackgroundService( pollingInterval: TimeSpan.FromSeconds(2), batchSize: 100); ``` It registers the processor, the polling options and the hosted service. On each tick it drains the outbox batch after batch until a batch delivers nothing, each batch in its own service scope so the processor and the handlers get a fresh context. A failure in a tick is logged and the next tick tries again. To drive it yourself, from a job scheduler or a test, register the processor alone: ```csharp builder.Services.AddOutboxProcessor(); ``` ```csharp var processor = scope.ServiceProvider.GetRequiredService>(); var delivered = await processor.ProcessPendingAsync(batchSize: 100, cancellationToken); ``` `ProcessPendingAsync` loads the pending messages that are due, oldest first, by `CreatedAt`, then `OccurredAt`, then `Id`, delivers each one on its own, and returns how many were delivered successfully. The processor throws at construction if the outbox is not enabled, or if it has neither a sink nor a dispatch delegate to deliver through. Each message is saved through the same scoped context, so a handler that resolves that context and changes an aggregate commits its change together with the processed mark. Events raised by that change become new outbox rows. ### Failures, retries and poison messages A handler that throws does not stop the batch. The exception type and message are written to `LastError`, truncated at 4000 characters, `Attempts` is incremented, `ProcessedAt` stays null, and the processor moves to the next message. The message is retried later, and on success `LastError` is cleared. A failed message is not tried again straight away. The processor records in `NextAttemptAt` when it may be, and loads only the messages that are due, still oldest first. The wait grows with every failure: | After attempt | 1 | 2 | 3 | 4 and later | |---|---|---|---|---| | The message waits | 5 s | 25 s | 2 min 5 s | 10 min | The wait does two things. A message that keeps failing does not hold back the messages written after it: the next poll reaches past it. And a sink that is down for a few seconds costs a message one attempt, not all of them, because a busy drain does not load it again before its time. `RetryDelay` sets the schedule. It is given the number of attempts made so far, 1 after the first failure: ```csharp options.UseOutbox(outbox => outbox.RetryDelay = attempts => TimeSpan.FromSeconds(30 * attempts)); ``` `TimeSpan.Zero` retries on the next poll. `OutboxOptions.DefaultRetryDelay` is the default schedule, for a policy that builds on it. `MaxAttempts` defaults to 10. Messages that reached it are no longer loaded: ```csharp options.UseOutbox(outbox => outbox.MaxAttempts = 5); ``` With both defaults, a message that keeps failing is given up on about an hour after its first attempt. It stays in the table with its last error for you to inspect, and with no `NextAttemptAt`, so resetting `Attempts` retries it on the next poll. To retry a message that is still waiting, set its `NextAttemptAt` to null. Delivered rows stay too, until something deletes them. `services.AddDomainEventRetention(...)` deletes them once they are older than a window you choose, and never touches a row that was not delivered. See [Keeping the tables small](https://dylansnel.github.io/DDDToolkit/docs/integration-events.md#keeping-the-tables-small). A batch that delivers nothing ends the background service's drain for that tick, whether its messages failed or none was due. A sink that is down is asked about one batch a tick, not about every message in a loop. The next tick carries on: the messages behind the ones that failed, and those that failed once their time has come. ### An outbox per context `UseOutbox(...)` configures one outbox shared by every context. With a context per module, give each producing module an outbox of its own, registered by that module: ```csharp services.AddDDDToolkitEntityFramework(options => options.UseOutbox(outbox => { outbox.RegisterEventsFromAssemblyContaining(); outbox.SendToModules(); })); services.AddOutboxBackgroundService(TimeSpan.FromSeconds(2)); ``` That works because `AddDDDToolkitEntityFramework` can be called as often as you like. The first call registers everything, and every call configures the one options object the process has. That is what lets each module of a modular monolith register its own part next to its own context: its outbox with `options.UseOutbox(...)`, the contracts it reads with `options.MapIntegrationEvents(...)`. The host keeps what is process-wide, such as `DispatchWithMediator()`. The dispatch delegate can only be set once; a second call throws instead of quietly handing one module's events to another module's publisher. See [Integration events](https://dylansnel.github.io/DDDToolkit/docs/integration-events.md#the-common-case-another-module-in-this-process) for the module registration end to end. A context uses its own outbox when it has one, the shared one when it has not, and none at all when neither exists: its events are then dispatched in process at save time, as if the outbox were not there. `options.OutboxFor(typeof(TContext))` says which of the three applies. A processor for a context without an outbox refuses to start and names the context. ### Honest limits - **Ordering is best-effort.** Messages are loaded oldest first, but a failed message waits while messages written after it are delivered, so delivery order is not the order of writing. - **A waiting message waits out its time.** Once a sink is back, a message that failed during the outage is not sent until its `NextAttemptAt`, up to 10 minutes later with the default schedule. Nothing tells the processor the sink recovered. Set `NextAttemptAt` to null to send the waiting messages at once. - **A slow failure still takes its time.** The wait keeps a failing message from being tried too often, but each attempt still lasts as long as the sink takes to fail, and a batch is delivered one message at a time. A batch of 100 messages that each wait out a 30 second timeout holds the processor for 50 minutes. Keep sink timeouts short, or the batch small. - **Concurrent processors can double-deliver.** The processor takes no lock, so two instances polling the same table may both pick up the same row. Combined with the crash window before `ProcessedAt` is written, that is the at-least-once guarantee: handlers must be idempotent, keyed on `EventId`, which is `OutboxMessage.Id`. - **Delivery is not immediate.** It happens on the next poll, not at commit. - **Two sinks share one row.** When a message goes to more than one sink and one of them refuses, the message as a whole is retried and the sinks that accepted it see it again. See [Integration events](https://dylansnel.github.io/DDDToolkit/docs/integration-events.md#when-one-sink-fails-and-another-does-not). `Tests/DDDToolkit.EntityFramework.Tests/OutboxTests.cs` exercises the transactional write, the retries, `MaxAttempts`, unknown event names and the background service, and `OutboxRetryTests.cs` next to it the wait between attempts. ## The dispatch loop Both modes run through the same loop in `PublishDomainEventsInterceptor`, once per `SaveChanges`: 1. Dequeue the pending events of every tracked aggregate. If there are none, stop. 2. In outbox mode, add a row per event and go back to step 1. 3. Otherwise call the dispatch delegate with the whole batch, then call `DetectChanges`, then go back to step 1. The loop exists because a handler may change a tracked aggregate, which may raise a further event. `MaxDispatchRounds` caps it at 10 by default. If a further batch of events appears after that many rounds, `SaveChanges` throws an `InvalidOperationException` naming the events still pending and suggesting either a handler that triggers itself or a higher limit: ```csharp options.MaxDispatchRounds = 20; ``` ## When no delivery mode is configured If an aggregate has pending events and neither `DispatchInProcess` nor `UseOutbox` was called, `SaveChanges` throws rather than dropping the events. The message names the aggregate types involved and both calls that would fix it. A save with no pending events needs no delivery mode, so a context that only writes aggregates which raise nothing works out of the box. ## Prefer `SaveChangesAsync` The dispatch delegate is asynchronous. Synchronous `SaveChanges` therefore blocks on it. That is safe in console applications and in ASP.NET Core, which have no synchronization context, but it can deadlock under a UI or legacy ASP.NET synchronization context. Both overloads are otherwise identical: events are dispatched before the write either way. ## Why Mediator and not MediatR MediatR did this job for the first two major versions of the toolkit and does it well. From version 13 it is commercially licensed. This repository prefers dependencies its users can take for free, so the examples and `DDDToolkit.Mediator` target Mediator, which is MIT and source generated rather than reflection based. Nothing here stops you using MediatR: write [the delegate](https://dylansnel.github.io/DDDToolkit/docs/event-delivery.md#the-delegate) and it publishes through `IPublisher` exactly as it always did. ## Where to look next - [Entity Framework](https://dylansnel.github.io/DDDToolkit/docs/entity-framework.md) for the registration, the mapping and the other interceptors. - [Domain events](https://dylansnel.github.io/DDDToolkit/docs/domain-events.md) for raising events and giving them stable names. - [Integration events](https://dylansnel.github.io/DDDToolkit/docs/integration-events.md) for sinks, published contracts and the inbox. - [Invariants](https://dylansnel.github.io/DDDToolkit/docs/invariants.md#at-the-save) for the check that runs after the handlers, before the write. # Row level security Postgres's row level security decides, for every query, which rows the caller gets to see and change, from policies on the tables. It does not apply to the tables' owner, and an application usually logs in as the owner, so the policies guard whatever else reaches the database and not the application. `DDDToolkit.EntityFramework.Postgres` changes that: every connection a context opens runs as the caller, so the policies decide what the application sees too. And the policies themselves can be written in C#, next to the aggregate they guard, as rules the generator turns into SQL when the code compiles. This page is about Postgres, any Postgres. [Supabase](https://dylansnel.github.io/DDDToolkit/docs/supabase.md#row-level-security-for-your-own-queries) has the same thing with Supabase's roles and `auth` functions, and its build writes the policies into `supabase/migrations` for you. ## Install ```bash dotnet add package Temp.DDDToolkit.EntityFramework.Postgres ``` ## Running queries as the caller ```csharp builder.Services.AddPostgresRowLevelSecurity(); builder.Services.AddDbContext((provider, options) => options .UseNpgsql(connectionString) .UsePostgresRowLevelSecurity(provider)); ``` Every time the context opens a connection, the interceptor sets a role and the caller's token claims on it, the way PostgREST does for each request: ```sql SELECT set_config('role', 'authenticated', false), set_config('request.jwt.claims', '{"sub":"…"}', false) ``` | The caller | Runs as | `ddd.caller_id()` | |---|---|---| | A signed-in user | `authenticated`, with their token's claims | the user's id | | A request without a user | `anon`, with the claims `{"role":"anon"}` | `null` | | No request at all: an outbox poller, a hosted service | `SystemRole`, or the role the application logged in as | `null` | | Code inside `using (Callers.Begin(caller))` | that caller, whatever the request says | the caller's | The roles are PostgREST's by default; `AddPostgresRowLevelSecurity(options => ...)` changes them. **Who is calling** is the toolkit's `Caller`, and an `ICallerAccessor` says which one it is at this moment. A host registers the accessor that knows its callers: `AddSupabaseJwtBearer` from `DDDToolkit.Auth.Supabase.AspNetCore` answers from the request, and `DDDToolkit.Auth.Supabase.AzureFunctions` from the invocation. Without one, the answer is whatever `Callers.Begin` made current, or the system outside any, which is what a worker service or a test wants. A caller comes from the claims of a validated token, `Callers.FromClaims(claims)`, or is made in code, `Caller.User(id)`, in which case the database sees its id and role and no other claims. ```csharp // a job queued on a user's behalf, with the claims of the token that queued it using (Callers.Begin(Callers.FromClaims(job.Claims))) { await handler.HandleAsync(job, cancellationToken); } ``` `Callers.Begin` follows `await` and `Task.Run` the way an `AsyncLocal` does, and a caller begun in code wins over the request's. The same works the other way round, for a step a request takes on the application's behalf: `Callers.Begin(Caller.System)`. **Background work** runs as `SystemRole`, or, left unset, as the role the application logged in as, which as the owner sees every row. For an application that should not be able to see everything by accident, log in as a role of its own that may do nothing but switch roles, and set `SystemRole` to a role with `BYPASSRLS`. A query outside a request that nobody meant to run as the system then fails instead of seeing every row. **How the settings travel.** They are set on the connection when a context opens it, because Entity Framework opens a connection for each query outside a transaction, and cost one round trip each time. Npgsql clears them with `DISCARD ALL` before the pooled connection is used again. Three ways of connecting would let them reach somebody else, and the interceptor refuses them before connecting: `No Reset On Close`, which skips that reset; `Multiplexing`, which shares a connection between callers at once; and Supabase's transaction pooler on port 6543, which hands each transaction whichever server connection is free. A connection you open yourself and hand to `UseNpgsql(connection)` gets no settings at all. ## A Postgres of your own A Supabase project has the roles and the functions a policy asks about the caller. A Postgres of your own has neither until `PostgresRowAccess.SetupScript()` makes them: `anon` and `authenticated`, granted to the role the application logs in as so it may switch to them, and `ddd.caller_id()`, `ddd.caller_role()` and `ddd.caller_claims()`, which read the claims the interceptor set. It can run again, so it belongs in an early migration: ```csharp public partial class RowLevelSecurity : Migration { protected override void Up(MigrationBuilder migrationBuilder) { migrationBuilder.Sql(PostgresRowAccess.SetupScript()); migrationBuilder.Sql(""" grant usage on schema ordering to anon, authenticated; grant select, insert, update, delete on all tables in schema ordering to anon, authenticated; alter default privileges in schema ordering grant select, insert, update, delete on tables to anon, authenticated; """); } } ``` The grants give the two roles the tables; the policies then decide which rows. `ddd.caller_id()` is `null` for a token whose `sub` is not a `uuid`, so a rule about a user matches nobody rather than failing every query; `Caller.UserId` is `null` for it too. ## Row access rules written in C# A rule is a static method on a class of its own, marked with the aggregate it guards and what it allows: ```csharp [RowAccess(RowOperations.Read | RowOperations.Change)] public static partial class ACustomerSeesTheirOrders { public static bool Allows(Order order, Caller caller) => order.PlacedBy == null || order.PlacedBy?.Value == caller.UserId; } ``` When the class compiles, the generator translates `Allows` into SQL and writes it into the class as `RowAccessSql`. The rule stays an ordinary method as well, so a handler, a test or a screen asks the same rule in C#: `ACustomerSeesTheirOrders.Allows(order, caller)`. ```mermaid flowchart LR Rule["ACustomerSeesTheirOrders.Allows
in C#"] -->|"the generator,
when it compiles"| Template["RowAccessSql:
SQL with the columns
still to fill in"] Template --> Script["PostgresRowAccess.Script:
columns from the
Entity Framework model"] Script --> Policies["CREATE POLICY
on the aggregate's tables"] Rule -->|"called directly"| Handler["a handler, a test"] ``` What the generator translates, and into what: | In `Allows` | In the policy | |---|---| | `order.PlacedBy == null \|\| order.PlacedBy?.Value == caller.UserId` | `("PlacedBy" IS NULL) OR ("PlacedBy" IS NOT DISTINCT FROM ddd.caller_id())` | | `order.Status != OrderStatus.Cancelled` | `"Status" IS DISTINCT FROM 2`, or `'Cancelled'` when the column stores it as text | | `order.Total.Amount <= 500` | `coalesce("Total_Amount" <= 500, FALSE)` | | `caller.IsSignedIn && !order.IsPublic` | `(ddd.caller_id() IS NOT NULL) AND (NOT "IsPublic")` | | `caller.Claim("app_metadata.team") == order.Team` | `ddd.caller_claims() #>> '{app_metadata,team}' IS NOT DISTINCT FROM "Team"` | | `caller.Role == "authenticated"` | `ddd.caller_role() IS NOT DISTINCT FROM 'authenticated'` | `==` is C#'s equality, nulls included, so it becomes `IS NOT DISTINCT FROM`, and a comparison that SQL would leave unknown is false, as it is in C#. The caller functions are wrapped in `(SELECT ...)` in the policy itself, so Postgres asks them once per query rather than once per row. Column names come from the Entity Framework model when the policies are written, so renaming a property or its column changes the policy with it. A value object stored inline is its columns, and a typed id's `.Value` is the id's column. What the database cannot check is a compile error on that expression: a method call such as `order.Team.StartsWith("n")`, `DateTime.UtcNow`, anything about another aggregate. For those, `Sql.Call` and `Sql.Raw` write SQL into the rule: ```csharp public static bool Allows(Order order, Caller caller) => order.PlacedBy?.Value == caller.UserId || Sql.Call("support.is_agent", caller.UserId); ``` The arguments are translated like the rest; the function is yours to create in a migration. A rule that uses either is the database's only: called in C#, it throws, because C# cannot run the SQL. **Entities follow their aggregate.** A rule is on the root. The tables of the aggregate's entities, an order's lines, get a policy of their own that asks the root's table, which answers under the rules, so an aggregate is visible whole or not at all and Entity Framework never loads half of one. **Roles.** A rule is for `anon` and `authenticated` unless `To` says otherwise: `[RowAccess(RowOperations.Read, To = new[] { "authenticated" })]`. Several rules on one aggregate add up: a row one of them allows is allowed. [DDD00038](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00038) to [DDD00041](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00041) are the rules the generator enforces: the class's shape, what it can translate, that the type is an aggregate root, and that a rule asks an access function about the aggregate's entities. ### Asking the aggregate's entities: access functions Whether the caller is one of a project's members is a question about the project's entities, and a rule cannot ask it itself. The tables of the entities have policies that ask the aggregate's table whether their row is visible, so a policy on the aggregate's table that read them would ask itself, and Postgres stops the query with infinite recursion ([DDD00041](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00041)). Put the question in an access function, written the way a rule is, and let the rules call it: ```csharp [AccessFunction("projects.is_member")] public static partial class ProjectMembership { public static bool Allows(Project project, Caller caller) => project.Members.Any(member => member.UserId == caller.UserId); } [RowAccess(RowOperations.Read | RowOperations.Change)] public static partial class MembersWorkOnTheirProjects { public static bool Allows(Project project, Caller caller) => ProjectMembership.Allows(project, caller); } ``` The function becomes one SQL function. It runs as its owner, `SECURITY DEFINER`, so it reads the members without their policies, and with an empty search path, so nothing in the caller's session changes what it reads. The rule asks it about the row: ```sql CREATE OR REPLACE FUNCTION projects.is_member(uuid) RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER SET search_path = '' AS $function$ SELECT EXISTS (SELECT 1 FROM projects."Projects" root WHERE root."Id" = $1 AND (EXISTS (SELECT 1 FROM projects."ProjectMember" e1 WHERE e1."ProjectId" = root."Id" AND ((e1."UserId" IS NOT DISTINCT FROM (SELECT ddd.caller_id())))))) $function$; CREATE POLICY "Members work on their projects (read)" ON projects."Projects" FOR SELECT TO anon, authenticated USING (projects.is_member("Id")); ``` **It is made once.** Only the context that maps `Project` writes the function, however many modules see the class that declares it, and it writes it before the policies that call it. A later script replaces it in place, so the policies of other modules that call it keep working, and drops the functions the context no longer declares. Two access functions with one name are refused. **Asked by a key.** The generator adds `Name` and an `Allows` that takes the aggregate's key, for a rule that holds a project's id rather than the project: `ProjectMembership.Allows(task.ProjectId)` becomes `projects.is_member("ProjectId")`. Only the database can answer that one; called in C#, it throws `DatabaseOnlyException`. Where C# holds the project, `ProjectMembership.Allows(project, caller)` answers in memory, because the members are loaded with the project. **Other modules** see only the Projects module's contracts, which cannot hold the definition, since it reads the module's own aggregate. So the contracts publish it by its key, and the definition takes its name from there: ```csharp // Projects.Contracts [AccessFunctionContract("projects.is_member")] public static partial class ProjectMembers; // Projects [AccessFunction(ProjectMembers.Name)] public static partial class ProjectMembership { public static bool Allows(Project project, Caller caller) => project.Members.Any(member => member.UserId == caller.UserId); } // Tasks [RowAccess(RowOperations.All)] public static partial class ProjectMembersWorkOnItsTasks { public static bool Allows(ProjectTask task, Caller caller) => ProjectMembers.Allows(task.ProjectId); } ``` The function's name is written once, and the key is typed: a rule cannot pass a task's id where a project's is asked. The Supabase build writes the access file of the module that owns a function before the files of the modules that call it, and refuses a rule that asks a function no module defines. `Sql.Call` stays for functions defined outside C#, in a migration of your own: an existing `app.has_permission('project.update', "Id")`, say. The build does not look for those among the access functions; they are yours. `Any` is over the aggregate's own collections, with or without a condition; an entity's entities are not reachable from it. ### Writing the policies `PostgresRowAccess.Script(context, rules, accessFunctions)` returns the SQL for one context: it first drops every policy an earlier script made on the context's tables, found by the comment each one carries, then the access functions the context no longer declares, and then makes its access functions and the rules' policies again. So the latest script says what the rules are now, a rule taken out disappears with it, and a policy you wrote by hand is left alone. ```csharp var sql = PostgresRowAccess.Script(context, [ RowAccessRule.For("A customer sees their orders", RowOperations.Read | RowOperations.Change, ACustomerSeesTheirOrders.RowAccessSql), ]); ``` Put the SQL it returns into a migration when the rules change, as text rather than as the call: a migration renders nothing when it runs later, on a database that is only as far as that migration. That is what the Supabase build does by itself, into `supabase/migrations`, from every rule of every module the host references; see [Supabase](https://dylansnel.github.io/DDDToolkit/docs/supabase.md#row-access-rules-in-the-build). ## Where to look next - [Supabase](https://dylansnel.github.io/DDDToolkit/docs/supabase.md#row-level-security-for-your-own-queries) for Supabase Auth's tokens, and the build that writes the policies. - [Designing aggregates](https://dylansnel.github.io/DDDToolkit/docs/aggregate-design.md), because a policy is about an aggregate, not a row. - [Diagnostics](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00038) for the build errors about a rule. # Supabase The Supabase CLI does not run Entity Framework migrations. It runs the SQL files in `supabase/migrations`, and so do `supabase db reset` and Supabase branching. `DDDToolkit.EntityFramework.Supabase` turns each Entity Framework migration into one of those files, so you keep writing migrations with `dotnet ef migrations add` and Supabase applies them. ## Install ```bash dotnet add package Temp.DDDToolkit.EntityFramework.Supabase ``` It depends on Entity Framework's relational layer and the dependency injection abstractions, and brings its own source generator: not on Npgsql, which your application already brings, and not on the rest of the toolkit, so it works for any context that has migrations. Reference it from the module that holds the context. From a change to a model to a database that has it: ```mermaid flowchart LR Add["dotnet ef migrations add, in the module"] --> Build["dotnet build, of the host"] Build --> Files["supabase/migrations, one .ddd.sql file per migration"] Files --> Commit["committed with the change"] Commit --> Push["supabase db push, or a preview branch"] Push --> Start["the host starts, and checks every module's migrations were applied"] Build -. "in CI the build only checks, and fails when a file is missing" .-> Files ```
Show the code: the three places it is switched on The design-time factory of each module's context is marked: ```csharp [SupabaseMigrations] public sealed class OrderingContextFactory : IDesignTimeDbContextFactory { public OrderingContext CreateDbContext(string[] args) { var options = new DbContextOptionsBuilder(); OrderingContext.UsePostgres(options, "Host=unused"); return new OrderingContext(options.Options); } } ``` The host's project file turns the export on, writing locally and only checking in CI: ```xml Write Check ``` And the host refuses to start against a database that lacks a migration: ```csharp // in the module services.AddSupabaseMigrations(); // in the host var app = builder.Build(); await app.Services.EnsureSupabaseMigrationsAppliedAsync(); ```
## Exporting as part of the build The export needs a context on Npgsql, but it never connects, so a connection string that points nowhere is enough. That is exactly what the design-time factory `dotnet ef` already uses gives you. Mark it: ```csharp [SupabaseMigrations] public sealed class OrderingContextFactory : IDesignTimeDbContextFactory { public OrderingContext CreateDbContext(string[] args) { var options = new DbContextOptionsBuilder(); OrderingContext.UsePostgres(options, "Host=unused"); return new OrderingContext(options.Options); } } ``` and turn the export on in the project that references every module: the host of a modular monolith, the presentation layer of a service. ```xml Write Check ``` That is all. Nothing in `Program.cs`, no command to remember, no list of modules to keep up to date. - **`Write`** writes a file for every migration that has none, after every build. Locally that means the files exist by the time you commit, and `supabase start` or `db reset` has them. - **`Check`** only compares, and fails the build when a file is missing, changed or orphaned, naming each one. In CI that catches the migration somebody added without building locally. - **Unset** does nothing, which is every project but the one you turned it on in. The list of modules you do not keep is kept by the compiler. In the project that turned the export on, the package's generator finds every marked factory in the assemblies it references and writes them down as ordinary code: ```csharp title="DDDToolkit.SupabaseMigrationSources.g.cs" namespace DDDToolkit.EntityFramework.Supabase.Generated { internal static class SupabaseMigrationSources { public static IReadOnlyList All() => new SupabaseMigrationSource[] { SupabaseMigrationSource.For("Ordering"), }; [ModuleInitializer] internal static void ExportWhenTheBuildAsks() => SupabaseMigrationBuild.RunIfRequested(All); } } ``` Add a module with a marked factory and the next build exports its migrations too. `"Ordering"` is the module name the files carry, read from the module's `[assembly: Module("Ordering")]`. [How the build step works](https://dylansnel.github.io/DDDToolkit/docs/supabase.md#how-the-build-step-works) explains the module initializer. Commit the files. Supabase branching and the GitHub integration read `supabase/migrations` from the repository, so the files have to be there, and `Check` is what guarantees they are. If you do not use branching you can generate them in the pipeline instead, with `Write` in a release build followed by `supabase db push`, and leave them out of the repository. ## What the build writes Each file is named after its migration and its module: `20260922120000_AddOrders` in a project with `[assembly: Module("Ordering")]` becomes `20260922120000_AddOrders.ordering.ddd.sql`. Without a module attribute the context's name stands in, less its `Context`. Entity Framework ids and Supabase versions are both `yyyyMMddHHmmss` timestamps, so the two histories sort the same way, and a file written with `supabase migration new` takes its place between them by date. The Supabase CLI reads everything between the first underscore and `.sql` as the name, so `.ordering.ddd` is only there for people: next to the policies and triggers you write by hand, it says which files the build writes and which module each belongs to. The file is what `dotnet ef migrations script` writes for that one step, with three differences: - **No `START TRANSACTION` and `COMMIT`.** The CLI already runs a file, and the row it records in `supabase_migrations.schema_migrations`, in one transaction. It runs statements that cannot be in a transaction, such as `CREATE INDEX CONCURRENTLY`, on their own. - **Row level security on new tables in `public`.** Supabase serves `public` through its Data API and grants the `anon` and `authenticated` roles access to it. Without row level security, a table Entity Framework creates there can be read and written by anyone who has the publishable key. With it on and no policy, those roles see nothing. Your application still works, because it connects as the table's owner, and row level security does not apply to the owner, unless you [run its queries as the caller](https://dylansnel.github.io/DDDToolkit/docs/supabase.md#row-level-security-for-your-own-queries). Change `SupabaseMigrationOptions.RowLevelSecuritySchemas` to cover other schemas, or clear it to turn this off. Putting your tables in a schema the Data API does not expose, with `modelBuilder.HasDefaultSchema("app")`, is even simpler. The toolkit's own `ddd` schema is not exposed. - **A header comment naming the migration and its context.** That comment is how a file whose migration was removed gets recognised. One of the example's files, with most of its statements left out: ```sql title="supabase/migrations/20260923093256_TrackCheckout.ordering.ddd.sql, shortened" -- Exported by DDDToolkit from the Entity Framework migration 20260923093256_TrackCheckout of OrderingContext. -- Written from that migration; change the migration, not this file. ALTER TABLE ordering."Orders" ADD "CancellationReason" text; -- ... INSERT INTO ordering."__EFMigrationsHistory" ("MigrationId", "ProductVersion") VALUES ('20260923093256_TrackCheckout', '10.0.12'); ``` The `__EFMigrationsHistory` insert stays in. After Supabase applies a file, `GetPendingMigrations()` and `dotnet ef migrations list` agree that the migration is applied. Let one of the two apply migrations, not both. Supabase does not read Entity Framework's history, so if the application calls `Database.Migrate()` first, the CLI applies that migration a second time and fails. `Export` never overwrites or deletes a file. Supabase applies each version once, so rewriting a file that was already applied changes nothing on that database, and a database that has not applied it yet ends up with a different schema. Instead, the report (and `EnsureInSync`) lists: | Status | Meaning | |---|---| | `Missing` / `Created` | The migration had no file; `Export` writes it. | | `Changed` | The file is not what the migration generates now. Delete and export again only if it was never applied anywhere; otherwise make the change in a new migration. | | `VersionTaken` | Another file already has this timestamp. | | `Orphaned` | An exported file whose migration is gone, usually after `dotnet ef migrations remove`. | The comparison ignores line endings, and it ignores the Entity Framework version in the history insert, so a checkout with `\r\n` or an Entity Framework update does not show every file as changed. Files you wrote yourself, such as policies, triggers and storage buckets, are left alone. If a database already has these migrations from `dotnet ef database update`, tell Supabase once: `supabase migration repair --status applied ` for each exported version. ## Checking at start-up On Supabase the migrations are the CLI's to apply, so the application must not call `Database.Migrate()`. It can still refuse to run against an older schema. Each module registers its source, and the host checks them all once it is built: ```csharp // in the module services.AddSupabaseMigrations(); // in the host var app = builder.Build(); await app.Services.EnsureSupabaseMigrationsAppliedAsync(); ``` It asks each context's own migration history and throws a `SupabaseMigrationsPendingException` that names every context with migrations missing, and each missing migration. Registering the same context twice registers it once; with no sources registered it does nothing, which is what a module running on something other than Supabase wants. ## Several modules, one Supabase project A Supabase project is one database, so a modular monolith's modules share it. Give each module a schema of its own and a migration history table in that schema, and export them all into the same `supabase/migrations`: ```csharp public const string Schema = "ordering"; public static void UsePostgres(DbContextOptionsBuilder options, string connectionString) => options.UseNpgsql(connectionString, npgsql => npgsql.MigrationsHistoryTable(HistoryRepository.DefaultTableName, Schema)); protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.HasDefaultSchema(Schema); modelBuilder.AddDomainEventOutbox(Database); } ``` Each module's migrations only ever see its own history table, so neither can report the other's as pending. Supabase has one history, which interleaves the modules by timestamp. That is fine, because no module's migration touches another module's schema. A context only reports its own exported files as orphaned, and if two modules scaffold migrations in the same second, the second one is reported as `VersionTaken` rather than written. Module schemas are not in the Data API's `schemas` list in `supabase/config.toml`, so the Data API does not serve them and nothing needs row level security. `Examples/ModularMonolith.Supabase` does all of this for five modules. Each module's factory is marked `[SupabaseMigrations]`, next to its context, and its migrations are in its `Infrastructure/Persistence/Migrations` folder. When the host runs on Supabase, the shared [`ModuleDatabase.AddContext`](https://github.com/DylanSnel/DDDToolkit/blob/main/Examples/Shared/DDDToolkit.Examples.Hosting/ModuleDatabase.cs) registers the start-up check for each module. The host's project file turns the export on, `Write` locally and `Check` in CI, and `supabase/migrations` holds the committed files. See [its README](https://github.com/DylanSnel/DDDToolkit/blob/main/Examples/README.md#on-supabase) to run it against a local Supabase. ## Row level security for your own queries An application that connects as `postgres` is the owner of its tables, and row level security does not apply to the owner. So the policies you wrote for the Data API guard the Data API and nothing else: every query your application runs sees every row, and who may see what is the application's own code to get right. That is a fine design, and the default. If you would rather keep Supabase's policies as the authority, for the application as well as for supabase-js, run each request's queries as the user who sent it, the way PostgREST does. Row level security itself is Postgres's, and so is the package that does this, `DDDToolkit.EntityFramework.Postgres`, which this one brings; [Row level security](https://dylansnel.github.io/DDDToolkit/docs/row-level-security.md) has all of it. What is Supabase's is the token, the roles and `auth.uid()`, and that is what this section is about. ```bash dotnet add package Temp.DDDToolkit.Auth.Supabase.AspNetCore # an ASP.NET Core application dotnet add package Temp.DDDToolkit.Auth.Supabase.AzureFunctions # Azure Functions on the isolated worker ``` Both bring `DDDToolkit.Auth.Supabase`, which validates Supabase Auth's tokens without a web framework. One request, from the token supabase-js sends to the rows a module's query gets back: ```mermaid sequenceDiagram participant Client as supabase-js participant Api as your application participant Context as Ordering's context participant Postgres Client->>Api: GET /orders/42, token Api->>Api: validate the token Api->>Context: find order 42 Context->>Postgres: role and claims Context->>Postgres: SELECT the order Postgres-->>Context: rows the policies allow Context-->>Api: none: not theirs Api-->>Client: 404 ```
Show the code: switched on in the host, per context ```csharp builder.Services.AddAuthentication().AddSupabaseJwtBearer("https://.supabase.co"); builder.Services.AddSupabaseRowLevelSecurity(); builder.Services.AddDbContext((provider, options) => options .UseNpgsql(connectionString) .UseSupabaseRowLevelSecurity(provider)); // after Build() app.UseAuthentication(); ```
`AddSupabaseJwtBearer` validates the access tokens Supabase Auth issues, the ones supabase-js holds for a signed-in user: issued by `https://.supabase.co/auth/v1`, for the audience `authenticated`, and signed with a key the project publishes. A project still on the legacy JWT secret publishes none, and nor does the CLI's local stack unless you give it signing keys; for those, pass the secret: `AddSupabaseJwtBearer(url, jwt => jwt.UseSupabaseJwtSecret(secret))`. `AddSupabaseRowLevelSecurity` and `UseSupabaseRowLevelSecurity` are Postgres's row level security with the roles every Supabase project has. Every time a context opens a connection, the caller's role and the token's claims go on it, as PostgREST puts them on for each request: | The caller | Runs as | `auth.uid()` | |---|---|---| | A request with a valid Supabase access token | `authenticated`, with the token's claims exactly as signed | the user's id | | A request without one, or signed in some other way | `anon`, with the claims `{"role":"anon"}` | `null` | | No request at all: an outbox poller, a hosted service | `SystemRole`, or the role the application logged in as | `null` | | Code inside `using (Callers.Begin(caller))` | that caller, whatever the request says | the caller's | So `auth.uid()`, `auth.jwt()` and every policy on every table the context touches apply to its queries, and a policy you already test with pgTAP says what the application may see. Who is calling, work that leaves a request, and how the settings travel are the same on any Postgres; see [Running queries as the caller](https://dylansnel.github.io/DDDToolkit/docs/row-level-security.md#running-queries-as-the-caller). Connect through the session pooler on port 5432, or directly: the transaction pooler on port 6543 hands each transaction whichever server connection is free, and is refused. **The database needs to know about the application's roles.** `anon` and `authenticated` have no privileges in a schema of yours until you grant them some, in a migration you write by hand: ```sql grant usage on schema ordering to anon, authenticated; grant select, insert, update, delete on all tables in schema ordering to anon, authenticated; alter default privileges in schema ordering grant select, insert, update, delete on tables to anon, authenticated; ``` Keep such schemas out of the Data API's exposed schemas. Granted to `anon`, a table in an exposed schema is open to anyone holding the publishable key, as far as its policies let them; unexposed, only the application reaches it. Write policies for aggregates, not rows. A policy that hides some of an order's lines hands Entity Framework half an aggregate, and its invariants then check half the data. Put the rule on the root, and let the children follow it: `using (exists (select 1 from ordering."Orders" o where o."Id" = "OrderId"))` asks the orders table, which answers under its own policy, so an order is visible whole or not at all. Rules written in C# do this for you, as the next section shows. **Background work** runs as `SystemRole`, or, left unset, as the role the application logged in as. Logged in as `postgres`, that is the owner, as before. For an application that should not be able to see everything by accident, log in as a role of its own that may do nothing but switch roles, as PostgREST's `authenticator` does, and set `SystemRole = SupabaseRowLevelSecurity.ServiceRole`: ```sql create role shop_app login noinherit password '...'; grant anon, authenticated, service_role to shop_app; ``` `Examples/ModularMonolith.Supabase` turns this on with `Supabase:Url`. An order there is its customer's: it knows who placed it, and two rules written in C#, `ACustomerHasTheirOrders` and `NobodyOrdersForSomebodyElse`, become its policies, as the next section describes. A signed-in customer sees their own orders, and a guest's order stays anybody's with its id. ### Row access rules in the build [Row access rules written in C#](https://dylansnel.github.io/DDDToolkit/docs/row-level-security.md#row-access-rules-written-in-c) go into `supabase/migrations` with the migrations. The build finds every `[RowAccess]` rule in the modules the host references and writes, for each module with rules, a file of its own, `{version}_access.{module}.ddd.sql`, whose policies ask `auth.uid()` and `auth.jwt()`. The [access functions](https://dylansnel.github.io/DDDToolkit/docs/row-level-security.md#asking-the-aggregates-entities-access-functions) a module's rules call go into the file of the module that maps their aggregate, before its policies, and that file is written before the files of the modules that call them: ```sql -- Written by DDDToolkit from the row access rules of OrderingContext. DO $ddd$ ... $ddd$; -- drops the policies the previous file made on the module's tables ALTER TABLE ordering."Orders" ENABLE ROW LEVEL SECURITY; CREATE POLICY "A customer has their orders (read)" ON ordering."Orders" FOR SELECT TO anon, authenticated USING (("PlacedBy" IS NULL) OR ("PlacedBy" IS NOT DISTINCT FROM (SELECT auth.uid()))); COMMENT ON POLICY "A customer has their orders (read)" ON ordering."Orders" IS 'DDDToolkit row access rule'; -- OrderLine belongs to the aggregate, and is seen and changed with it. ALTER TABLE ordering."OrderLine" ENABLE ROW LEVEL SECURITY; CREATE POLICY "OrderLine goes with its Orders" ON ordering."OrderLine" FOR ALL TO anon, authenticated USING (EXISTS (SELECT 1 FROM ordering."Orders" parent WHERE parent."Id" = "OrderLine"."OrderId")) WITH CHECK (EXISTS (SELECT 1 FROM ordering."Orders" parent WHERE parent."Id" = "OrderLine"."OrderId")); ``` The file says what the rules are now: it drops every policy an earlier one made, found by the comment each carries, and makes them all again, so a rule taken out disappears and a policy you wrote by hand is left alone. A file already written is never written again, because Supabase may have applied it. A rule that changes, or a migration of the module that comes after the last file, gets a new file, numbered after everything else in the directory, and `Check` in CI fails until it is there. A policy stands in the way of dropping a column it reads, so every migration of a module with rules starts by taking the module's generated policies off. The access file after it puts them back. ### In Azure Functions The isolated worker runs no ASP.NET Core pipeline, not even with its ASP.NET Core integration, so there is no `UseAuthentication()` to hang a bearer scheme on. `DDDToolkit.Auth.Supabase.AzureFunctions` is a worker middleware instead: for every HTTP-triggered invocation it validates the token in the request's `Authorization` header and runs the function inside `Callers.Begin`, as that user, or as `anon` without a valid token. ```csharp var builder = FunctionsApplication.CreateBuilder(args); builder.UseSupabaseAuth(); builder.Services.AddSupabaseAuth("https://.supabase.co"); builder.Services.AddSupabaseRowLevelSecurity(); builder.Services.AddDbContext((provider, options) => options .UseNpgsql(connectionString) .UseSupabaseRowLevelSecurity(provider)); ``` A queue, timer or Service Bus trigger has no request and runs as the system, unless the function begins a caller itself, say from the claims its message carries. `context.GetSupabaseCaller()` says who an invocation runs as, for a function that answers somebody without a user with a 401. ### Anywhere else A worker service or a tool of your own needs no host package. `SupabaseTokenValidator`, from `DDDToolkit.Auth.Supabase`, checks a token against the project's published keys and returns its `Caller`; `Callers.Begin` makes it current; and `AddSupabaseRowLevelSecurity()` alone asks for exactly that caller, or the system outside any. Pass `Callers.FromClaims(claims)` only the claims of a token something validated. ## Exporting by hand Everything the build does is also an API, for a test, a tool of your own or a context without a factory: ```csharp var ordering = SupabaseMigrationSource.For(); SupabaseMigrations.Export([ordering, shipping]); // writes what is missing SupabaseMigrations.EnsureInSync([ordering, shipping]); // throws unless everything is there ``` `SupabaseMigrationSource.For(() => ...)` takes a delegate instead of a factory. Given sources and no directory, `Export`, `Compare` and `EnsureInSync` find the Supabase project the way the CLI does: from the current directory upwards to the nearest `supabase/config.toml`. `SupabaseMigrations.FindDirectory(start)` does the same from a directory you choose. The three also take a single `DbContext`; that form does not search, and uses `supabase/migrations` under the current directory unless you pass one. ## How the build step works The build step runs your code at build time, so here is what it does. A source generator in the package looks, in the project that turned the export on, for every factory marked `[SupabaseMigrations]` in the assemblies it references, and writes the list as ordinary generic code, `SupabaseMigrationSource.For("Ordering")`, together with a module initializer, as shown under [Exporting as part of the build](https://dylansnel.github.io/DDDToolkit/docs/supabase.md#exporting-as-part-of-the-build). After the build, a target in the package starts the application it just built with one environment variable set. The module initializer runs before `Main`, sees the variable, exports, and ends the process. None of the application's own start-up runs: no host builder, no configuration providers, no Azure App Configuration, no hosted services. Without the variable, which is every other time the application starts, the initializer returns at once. Nothing is found or created by reflection; the factory is `new TFactory()`. It adds about a second to a build of that one project. A marked factory the generated code cannot create, because it is not public, has no public parameterless constructor or does not implement `IDesignTimeDbContextFactory`, is reported as [DDD00031](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00031) instead of being skipped. The package brings the generator and the build step to the host through the module that references it, so the host does not reference the package itself unless it uses the start-up check. ## Where to look next - [Entity Framework](https://dylansnel.github.io/DDDToolkit/docs/entity-framework.md#migrations) for migrations on any other database. - [Row level security](https://dylansnel.github.io/DDDToolkit/docs/row-level-security.md) for callers, work outside a request, and rules written in C#. - [Modules](https://dylansnel.github.io/DDDToolkit/docs/modules.md) for `[assembly: Module("Ordering")]`, the name the files carry. - [Diagnostics](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00031) for the build error about an unusable factory. # Modules Everything else in this toolkit is tactical: an id that cannot be mixed up, a value object that cannot be invalid, an aggregate that saves as one unit. None of it stops one part of your system from reaching into another part and taking whatever it finds. That is a strategic question, and in a modular monolith it is the only one that decides whether you still have modules a year from now. This page is about the one strategic rule a compiler can actually check: a module is a boundary, and you may only use what the module on the other side published. ## What a module is here **A module is an assembly.** One project, one module. ```csharp // Ordering/AssemblyInfo.cs, or any file in the project [assembly: Module("Ordering")] ``` That is the whole declaration. Everything the assembly declares belongs to the module, and everything it declares is internal to the module unless it says otherwise. Two assemblies may carry the same name, and then they are one module. That is how you split a module into `Ordering.Domain` and `Ordering.Infrastructure` without inventing a boundary between them. An assembly with no `[Module]` is not a module. It is never reported for, and never reported against. The framework, your NuGet packages, a shared kernel and every project you have not got round to yet all stay out of the way. Nothing changes in a codebase until somebody adds the attribute. ## What it publishes Once there are two modules, each one decides what the other may use. It marks those types `[ModuleContract]`, and every integration event it declares is published without being marked: ```csharp [ModuleContract] [EntityId("CUS")] public readonly partial record struct CustomerId; [ModuleContract] public sealed record CustomerSummary(CustomerId Id, string Name); ``` Everything else in the assembly, `public` or not, is the module's own business. Why a module would want that, what belongs in a contract and where to keep it is a page of its own: [Module contracts](https://dylansnel.github.io/DDDToolkit/docs/module-contracts.md). ## What the analyzer catches ### DDD00022, using what is not published ```csharp // in module Sales public sealed class OrderReport { public string Describe(Customer customer) => customer.Name; // DDD00022 } ``` `Customer` belongs to module `Crm` and `Crm` does not publish it. The rule reports wherever you *name* another module's unpublished type: a parameter, a field, a base type, a generic argument, an attribute, a `typeof`, a `new`, a static call, a `using` alias. One name, one warning. Two ways out. If the type really is part of the contract, mark it `[ModuleContract]` in the module that owns it, which is a decision taken by the team that owns it. If it is not, go through something that is: a published read model, an interface, an integration event. ### DDD00023, holding another module's entity ```csharp // in module Sales [AggregateRoot("ORD")] public partial class Order { public Customer Buyer { get; private set; } // DDD00023 } ``` This is the one that quietly ends a modular monolith, so it gets a rule of its own. A property typed as another module's entity is a navigation. Entity Framework will map it, a query in `Sales` will load rows belonging to `Crm`, and one `SaveChanges` will write into both modules inside one transaction. From that point on the two modules cannot be tested apart, migrated apart, or pulled into separate services without unpicking every query that crossed over. Nothing about the code looks wrong; it is one property. Hold the identifier instead, and let an integration event tell you when the other side changes: ```csharp [AggregateRoot("ORD")] public partial class Order { public CustomerId Buyer { get; private set; } public void PlaceFor(CustomerId customer) => Buyer = customer; } ``` **Publishing the entity does not help**, and the rule fires whether or not the entity is published. That is deliberate. `[ModuleContract]` says "you may name this type"; it cannot say "you may make this type part of your own transaction", because that is not the owner's to give. Like [DDD00021](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00021), this rule reads stored state only: fields and properties. Passing another module's entity into a method and reading it is not reported, because nothing is stored and Entity Framework builds nothing from it. It is still usually a sign that the call belongs on the other side of the boundary, but it is not what this rule is about. ### Both rules are warnings A module boundary is a design decision. The code compiles either way, nothing stops being generated, and a codebase adopting modules wants to see the list before it is forced to fix it. Adding `[assembly: Module]` to one project and getting forty errors would teach exactly one lesson: take the attribute off again. Once the list is empty, hold it: ```xml $(WarningsAsErrors);DDD00022;DDD00023 ``` Or drop a rule entirely, per project: ```xml $(NoWarn);DDD00023 ``` Unlike the generator diagnostics, these two come from a real analyzer, so `#pragma warning disable DDD00022` and `[SuppressMessage]` both work on a single line or member. Use them where you mean it and leave a reason next to them. ## What the analyzer cannot catch Read this list before you trust the rule, because the gaps are real. | Not caught | Why | |---|---| | Several modules inside one project | A module is an assembly here. See [below](https://dylansnel.github.io/DDDToolkit/docs/modules.md#why-not-namespaces) | | A type you never name, such as `var buyer = summary.Owner;` | The rule reports names in your source, and there is no name in that line | | An extension method called on an instance, `customer.Deactivate()` | You named the method, not the class that declares it | | A member inherited from an unpublished base type | Reporting it would fire on types the author never saw | | Reflection, DI by string, `dynamic`, serialization | Nothing about them is visible at compile time | | Generated code | Skipped on purpose; you cannot fix a file you do not write | | A published type that hands you an unpublished one | That is the publishing module's bug, and the rule is not clever enough to call it | The last one is worth designing against rather than hoping for: if a published type exposes an unpublished one, the contract is not really a contract. A published record of primitives and published ids has no such hole. ## How this fits with integration events The two halves are meant to be read together. [Integration events](https://dylansnel.github.io/DDDToolkit/docs/integration-events.md) explain how a message gets from one place to another and how to consume it once. This page is why you would bother instead of adding a project reference and a navigation property. The shape a module ends up with is small: - It publishes identifiers, so other modules can point at its things. - It publishes integration events, so other modules can react to its things. - It publishes a read model or an interface where somebody genuinely needs to ask it a question. - It keeps its entities, its aggregates, its repositories and its `DbContext` to itself. Two modules that share only that can be deployed together forever, and can be pulled apart on the day that stops being true. Two modules that share a navigation property cannot. ## One set of modules, any host A module registers everything it needs itself, so a host only chooses which modules it runs and how their messages travel. The example shop runs the same five modules as one process and as three services: ```mermaid flowchart TB subgraph monolith ["ModularMonolith: one host, messages in process or through one queue"] direction LR M1["Catalog"] ~~~ M2["Ordering"] ~~~ M3["Inventory"] ~~~ M4["Payments"] ~~~ M5["Shipping"] end subgraph services ["Microservices: three hosts, messages over pgmq, Wolverine or MassTransit"] direction LR Gateway["Gateway: one GraphQL schema"] --> Storefront["Storefront: Catalog, Ordering"] Gateway --> PaymentsService["Payments: Payments"] Gateway --> Fulfilment["Fulfilment: Inventory, Shipping"] end monolith ~~~ services ``` A module does not know which of the two it is in. What changes is the host's `Program.cs`, and the modules' boundaries are what make that possible: nothing crosses between them except contracts, and a contract travels as well over a queue as through a method call.
Show the code: two hosts over the same modules The monolith runs all five, and hands each module's messages to the others in process: ```csharp var host = ModuleHost.InProcess(database); builder.Services.AddCatalogModule(host); builder.Services.AddOrderingModule(host); builder.Services.AddInventoryModule(host); builder.Services.AddPaymentsModule(host); builder.Services.AddShippingModule(host); ``` *[`ModularMonolith.Supabase/DDDToolkit.Examples.Host/Program.cs`](https://github.com/DylanSnel/DDDToolkit/blob/main/Examples/ModularMonolith.Supabase/DDDToolkit.Examples.Host/Program.cs)* The storefront service runs two of them, and sends what the others need through pgmq: ```csharp var host = new ModuleHost( ModuleDatabase.Postgres(connectionString), outbox => { outbox.SendToModules(); // Catalog to Ordering, next door outbox.SendToPgmq(); // everything the other services handle }); builder.Services.AddCatalogModule(host); builder.Services.AddOrderingModule(host); ``` *[`Microservices.Pgmq/DDDToolkit.Examples.Pgmq.Storefront/Program.cs`](https://github.com/DylanSnel/DDDToolkit/blob/main/Examples/Microservices.Pgmq/DDDToolkit.Examples.Pgmq.Storefront/Program.cs)*
## One API over the modules: GraphQL A module's boundary holds in its API as well. Rather than one GraphQL schema that knows every module, each module can serve a schema of its own: its types, its queries, and its part of the types other modules own, keyed on a name and a key they agree on. Catalog declares `Product` with its name and price; Inventory declares its own `Product`, keyed on the same SKU, with the stock; Ordering says a line's product is the `Product` with that SKU. No module references another's classes, and a client still sees one `Product`. `DDDToolkit.HotChocolate.Fusion.InMemory` composes those schemas inside the monolith with HotChocolate Fusion, and calls the modules in memory. The same schemas compose across processes when a module becomes a service, so its GraphQL does not change on that day either. See [One schema over a modular monolith](https://dylansnel.github.io/DDDToolkit/docs/graphql.md#one-schema-over-a-modular-monolith), and `Examples/ModularMonolith.*` for five modules doing it. ## Adopting this on an existing codebase 1. Pick the module with the fewest things pointing at it and add `[assembly: Module]` to it. Nothing happens yet, because nothing else is a module. 2. Add `[assembly: Module]` to one of its callers. Now you get a list. 3. Work the list. Most entries are a published id that was never marked, or a query that should be a published read model. 4. Turn `DDD00023` into an error for those two projects when its list is empty, then `DDD00022`. 5. Repeat with the next module. The rules stay silent about every project you have not reached. ## Design choices ### Why not namespaces Several modules inside one project is the other common layout, and this analyzer does not support it. The reason is what an analyzer can see. It gets this compilation plus the *metadata* of everything the compilation references. When code in `Ordering` names a type from `Billing`, the analyzer has to ask that type which module it belongs to, and the only answer available is whatever survived into `Billing.dll`. An assembly attribute survives. A namespace cannot carry an attribute at all, so a namespace layout would have to be described by a convention that the other side cannot confirm, and a boundary you cannot confirm is not a boundary. There is a second reason to prefer a project per module, and it is the better one: the compiler already enforces `internal` at the assembly boundary. Put a module in its own project and half the job is done by C# itself. What the analyzer adds is the other half, which C# has no word for: *public, but not for you*. ### Why not the DDD_Module MSBuild property `DDD_Module` already exists in this toolkit and it is not this. It names the generated `Add{Module}Converters` and `Add{Module}GraphQlRuntimeBindings` methods, and it is an MSBuild property, which means it reaches the compiler of the project that sets it and travels no further. The compiler building `Ordering` cannot read what `Billing.csproj` set. Leave `DDD_Module` where it is; it is a naming knob, not a boundary. ## Related - [Module contracts](https://dylansnel.github.io/DDDToolkit/docs/module-contracts.md), what a module publishes and why. - [Integration events](https://dylansnel.github.io/DDDToolkit/docs/integration-events.md), the supported way across a boundary. - [One schema over a modular monolith](https://dylansnel.github.io/DDDToolkit/docs/graphql.md#one-schema-over-a-modular-monolith), the modules' GraphQL composed without a module knowing another. - [Entities and aggregates](https://dylansnel.github.io/DDDToolkit/docs/entities-and-aggregates.md#reference-other-aggregates-by-id), the same argument one scale down, inside a single module. - [Diagnostics](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00022), the reference entries for both rules. # Module contracts A [module](https://dylansnel.github.io/DDDToolkit/docs/modules.md) is only a boundary if something stays on the inside. This page is about the other half: what a module deliberately lets out, why that list should be short and written down, and where to keep it. You do not need any of it while your application is one module. It starts to matter the day a second module has to know something about the first. ## The problem Two modules in one solution, `Crm` and `Sales`. Sales needs a customer's name on an invoice, so it references the Crm project and uses what it finds there: ```csharp // in Sales public string InvoiceHeader(Customer customer) => $"Invoice for {customer.Name}"; ``` That is reasonable code, and so is the next step: an invoice holds its `Customer`, a Sales query joins Crm's table, a Sales handler calls Crm's repository. Each change is small and each one works. A year later Crm wants to split `Name` into first and last name, move its tables to a schema of its own, or run as a separate service. It cannot, because Sales depends on all of it. Nothing was ever decided; the boundary between the two modules simply stopped existing, one convenient reference at a time. C#'s `public` does not prevent this. It means "every assembly that references me", and Crm's types have to be public anyway, for Crm's own host, its tests and its other projects. The language has no word for *public, but not for other modules*. ## A contract: what a module promises The fix is to make the promise explicit. A module names the few types other modules may use, and everything else is its own to change. That short list is its **contract**: the same idea as the public API of a library, or the HTTP API of a service, except that it is inside one process and a compiler checks it. What that buys: - **The owner knows what it may change.** Anything not in the contract can be renamed, reshaped or deleted without asking anybody. Anything in it is a promise, and changing it is a decision. - **A consumer knows what it may rely on**, and cannot come to rely on anything else by accident. - **The module can leave the process.** When a module becomes a service, the contract is what the others kept using, so it is what crosses the network. There is nothing else to unpick. The [microservices samples](https://github.com/DylanSnel/DDDToolkit/blob/main/Examples/README.md) run the same five modules as three services for exactly that reason. What that looks like between two modules. Sales may hold Crm's identifier, read its summary and react to its event. Holding Crm's entity or naming its `DbContext` is what the analyzer reports: ```mermaid flowchart LR subgraph crm ["module Crm"] direction TB subgraph crmown ["its own business"] Customer["Customer, an aggregate"] CrmContext["CrmContext, its handlers, its queries"] end subgraph crmcontract ["its contract"] CustomerId["CustomerId"] Summary["CustomerSummary, a read model"] Registered["CustomerRegistered, an integration event"] end end subgraph sales ["module Sales"] direction TB Invoice["Invoice"] Welcome["a handler of CustomerRegistered"] end Invoice -->|"holds"| CustomerId Invoice -->|"reads"| Summary Welcome -->|"handles"| Registered Invoice -. "DDD00023" .-x Customer Welcome -. "DDD00022" .-x CrmContext linkStyle 3,4 stroke:#e5484d,color:#e5484d ```
Show the code: publishing, and what the analyzer says Crm says it is a module and publishes three types. Everything else it declares, `public` or not, stays its own: ```csharp [assembly: Module("Crm")] [ModuleContract] [EntityId("CUS")] public readonly partial record struct CustomerId; [ModuleContract] public sealed record CustomerSummary(CustomerId Id, string Name); [IntegrationEvent("crm.customer-registered")] public sealed record CustomerRegistered(Guid CustomerId, string Name); ``` In Sales, using what Crm published is fine, and the rest is reported: ```csharp [assembly: Module("Sales")] [AggregateRoot("INV")] public partial class Invoice { public CustomerId Customer { get; private set; } // fine: published public Customer Buyer { get; private set; } // DDD00023: another module's entity, stored } public sealed class InvoiceReport { public string Describe(CrmContext crm) => "..."; // DDD00022: named, and not published } ``` See [What the analyzer catches](https://dylansnel.github.io/DDDToolkit/docs/modules.md#what-the-analyzer-catches).
## What goes in a contract In rough order of how often you will want it: | Publish | Why | |---|---| | Integration events | The supported way for another module to learn that something happened. See [Integration events](https://dylansnel.github.io/DDDToolkit/docs/integration-events.md) | | Identifiers | Another module has to be able to point at your things, and an `OrderId` is safer to pass around than a `Guid` | | Read models and value objects | A copy of data another module can read, with no behaviour and no navigation | | Interfaces your module implements | A front door with a signature, for the rare question that cannot wait for an event | | Entities and aggregate roots | Almost never. See [below](https://dylansnel.github.io/DDDToolkit/docs/module-contracts.md#keeping-a-contract-a-contract) | A small contract is a good contract. The example shop's Ordering module publishes one identifier and three integration events, and the modules that react to orders need nothing more from it. The whole shop, drawn the same way: each arrow is a contract one module publishes and another handles. No arrow is a method call, and no module holds another's objects; an `OrderId` is all that travels with an order: ```mermaid flowchart LR Catalog["Catalog: products and prices"] Ordering["Ordering: orders, a copy of the prices"] Inventory["Inventory: stock and reservations"] Payments["Payments: payments"] Shipping["Shipping: shipments"] Catalog -->|"ProductListedV1, ProductPriceChangedV1"| Ordering Ordering -->|"OrderPlacedV1, OrderCancelledV1"| Inventory Ordering -->|"OrderPlacedV1, OrderCancelledV1"| Payments Inventory -->|"StockReservedV1"| Payments Inventory -->|"StockReservedV1, StockReservationFailedV1"| Ordering Payments -->|"PaymentSucceededV1, PaymentFailedV1"| Ordering Ordering -->|"OrderConfirmedV1"| Shipping ```
Show the code: one module's contracts, and another reading them What Ordering publishes, in its contracts project: ```csharp [ModuleContract] [EntityId("ORD")] public readonly partial record struct OrderId; [IntegrationEvent("ordering.order-placed", Version = 1)] public sealed record OrderPlacedV1( OrderId OrderId, string City, string PostalCode, IReadOnlyList Lines, decimal Total, string Currency); [IntegrationEvent("ordering.order-confirmed", Version = 1)] public sealed record OrderConfirmedV1(OrderId OrderId, string City, string PostalCode); [IntegrationEvent("ordering.order-cancelled", Version = 1)] public sealed record OrderCancelledV1(OrderId OrderId, string Reason); ``` *[`Ordering.Contracts/OrderingContracts.cs`](https://github.com/DylanSnel/DDDToolkit/blob/main/Examples/Modules/Ordering/DDDToolkit.Examples.Ordering.Contracts/OrderingContracts.cs)* Shipping reads one of them, and keeps the `OrderId` it carries: ```csharp [IntegrationEventConsumer("shipping.booker")] public sealed class BookShipment(ShippingContext context) : IIntegrationEventHandler { public Task HandleAsync(OrderConfirmedV1 contract, IntegrationEventMessage message, CancellationToken cancellationToken) { context.Shipments.Add(new Shipment( ShipmentId.CreateSequential(), contract.OrderId, $"{contract.PostalCode}, {contract.City}", message.OccurredAt)); return Task.CompletedTask; } } ``` *[`Shipping/Application/Shipments/IntegrationEvents/Inbound/BookShipment.cs`](https://github.com/DylanSnel/DDDToolkit/blob/main/Examples/Modules/Shipping/DDDToolkit.Examples.Shipping/Application/Shipments/IntegrationEvents/Inbound/BookShipment.cs)*
## Declaring it A module says what it publishes, type by type: ```csharp using DDDToolkit.Abstractions.Attributes; [ModuleContract] [EntityId("CUS")] public readonly partial record struct CustomerId; [ModuleContract] public sealed record CustomerSummary(CustomerId Id, string Name); [IntegrationEvent] // in module Crm, published as crm.customer-registered public sealed record CustomerRegistered(Guid CustomerId, string Name); ``` Three things are published there. `[ModuleContract]` publishes a type. An integration event is published without a second attribute, because a type whose whole job is to be read by somebody else is already a contract. A type nested inside a published type is published with it. Everything else in the assembly, `public` or not, is the module's own business. Once both sides are [modules](https://dylansnel.github.io/DDDToolkit/docs/modules.md#what-a-module-is-here), naming anything else from Crm in Sales is a warning ([DDD00022](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00022)), and so is storing Crm's entity in a Sales one ([DDD00023](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00023)). The Invoice from the problem above becomes: ```csharp // in Sales public string InvoiceHeader(CustomerSummary customer) => $"Invoice for {customer.Name}"; ``` `[ModuleContract]` on its own does nothing until the assemblies say which module they belong to. That is on purpose: adding it early costs nothing, and it starts to count when the second module arrives. ## A project of its own The attribute is enough to draw the line. The example shop goes one step further and gives each module's contract a project of its own: ``` Ordering/ DDDToolkit.Examples.Ordering/ the module: aggregates, persistence, handlers, endpoints DDDToolkit.Examples.Ordering.Contracts/ what it publishes: OrderId and three integration events Shipping/ DDDToolkit.Examples.Shipping/ references Ordering.Contracts, never Ordering ``` *[`Ordering.Contracts/OrderingContracts.cs`](https://github.com/DylanSnel/DDDToolkit/blob/main/Examples/Modules/Ordering/DDDToolkit.Examples.Ordering.Contracts/OrderingContracts.cs)* Every arrow is a project reference. The modules that react to orders reference Ordering's contracts, and nothing references Ordering itself: ```mermaid flowchart LR subgraph ordering ["module Ordering"] direction TB O["Ordering: aggregates, context, handlers"] --> OC["Ordering.Contracts: OrderId, integration events"] end Inventory["Inventory"] --> OC Payments["Payments"] --> OC Shipping["Shipping"] --> OC ```
Show the code: two projects, one module Both projects say they are Ordering: ```csharp // Ordering/Module.cs, and the first lines of Ordering.Contracts/OrderingContracts.cs [assembly: Module("Ordering")] ``` The contracts project publishes the identifier; its integration events are published by being integration events: ```csharp [ModuleContract] [EntityId("ORD")] public readonly partial record struct OrderId; [IntegrationEvent("ordering.order-confirmed", Version = 1)] public sealed record OrderConfirmedV1(OrderId OrderId, string City, string PostalCode); ``` Shipping references the contracts and nothing else of Ordering's, and holds the analyzer's rules as errors: ```xml $(WarningsAsErrors);DDD00022;DDD00023 ``` *[`DDDToolkit.Examples.Shipping.csproj`](https://github.com/DylanSnel/DDDToolkit/blob/main/Examples/Modules/Shipping/DDDToolkit.Examples.Shipping/DDDToolkit.Examples.Shipping.csproj)*
Both projects carry `[assembly: Module("Ordering")]`, so they are one module in two assemblies. Why split them: - **The compiler does most of the work.** Shipping references only the contracts project, so Ordering's aggregates, its `DbContext` and its handlers are not merely forbidden in Shipping, they are not there. No analyzer is needed to stop a navigation to `Order` when `Order` cannot be named at all. - **One file says what the module promises.** Reviewing a change to the contract means reviewing one small project, and a pull request that touches it is visibly a change to a promise. - **Consumers get few dependencies.** Referencing the contracts brings the contracts, not Ordering's Entity Framework model or its packages. The example's contracts project does reference `DDDToolkit.EntityFramework`, for one reason: the value converter for the published `OrderId` is generated into the assembly that declares the id, and Shipping stores an `OrderId` in a column. For a small codebase the split can wait. `[ModuleContract]` in the module's own project, with the analyzer watching the other modules, draws the same line with one project fewer. ## Keeping a contract a contract - **Publish identifiers and primitives, not your value objects.** Ordering's `OrderPlacedV1` carries `City` and `PostalCode`, not Ordering's `Address`, and the total as an amount and a currency, not as `Money`. A consumer that deserialized `Address` would be coupled to a type Ordering expects to change freely. - **A published type must not hand out an unpublished one.** If `CustomerSummary` had a `Customer` property, the contract would leak the entity it was meant to hide. The analyzer cannot see this ([What the analyzer cannot catch](https://dylansnel.github.io/DDDToolkit/docs/modules.md#what-the-analyzer-cannot-catch)); a record of primitives and published ids has no such hole. - **Once published, a change is a breaking change.** Somebody deployed against it. Add a new version of an integration event instead of changing the old one; see [Versioning and upcasting](https://dylansnel.github.io/DDDToolkit/docs/integration-events.md#versioning-and-upcasting). - **Entities stay home.** Publishing an entity lets another module name it, and [DDD00023](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00023) still fires when that module stores it. `[ModuleContract]` says "you may name this type"; it cannot say "you may make it part of your own transaction", because that is not the owner's to give. Publish the identifier and a read model instead. ## Related - [Modules](https://dylansnel.github.io/DDDToolkit/docs/modules.md): what a module is, and the analyzer that holds the line. - [Integration events](https://dylansnel.github.io/DDDToolkit/docs/integration-events.md): the contract that carries news from one module to another, and how it is delivered and consumed once. - [Entities and aggregates](https://dylansnel.github.io/DDDToolkit/docs/entities-and-aggregates.md#reference-other-aggregates-by-id): the same argument one scale down, between two aggregates of one module. # Integration events When something happens in one module, another module often has to react. Ordering places an order and Billing raises an invoice for it. Billing cannot simply handle Ordering's domain event: that means referencing Ordering's domain assembly, with its identifiers, its value objects and whatever else the event touches, and from then on Ordering cannot change any of it without breaking Billing's build. An integration event is what Ordering publishes instead: a small record, usually of primitives, owned by Ordering, that Billing reads without knowing anything else about Ordering. This page is about declaring it, getting it to whoever has to react, and consuming it safely at the other end. It starts where [the outbox](https://dylansnel.github.io/DDDToolkit/docs/event-delivery.md#the-outbox) stops: the outbox makes a domain event durable, and this is the other half. Most of the time the module that reacts is in the same process. That is the case this page is written around, because it is the common one. When a module moves to a process of its own, only the way the message travels changes; that is on [Transports](https://dylansnel.github.io/DDDToolkit/docs/transports.md). `DDDToolkit.EntityFramework` gives you four things. A seam between the event you raise and the message you publish, so the two can change at different speeds. A sink interface, so the outbox has somewhere to deliver to, with an in-process module sink ready made. An inbox, so a consumer can be handed the same message twice without doing the work twice. And versioning, so a payload written by last year's build is still readable by this year's. It does not give you a bus. Between processes it hands its messages to pgmq, Wolverine or MassTransit, and keeps only the outbox and the inbox on either side; see [Transports](https://dylansnel.github.io/DDDToolkit/docs/transports.md). ## Two kinds of event They look the same in C# and they are not the same thing. | | Domain event | Integration event | |---|---|---| | Who reads it | Code inside one module | Other modules, other services, other teams | | Who owns the shape | You, today | Everyone who deployed against it | | Renaming a field | A refactor | A breaking change | | Carries | Your identifiers, your value objects | Primitives, usually | | Lifetime | As long as the aggregate | As long as the oldest consumer | A domain event is internal. `OrderPlaced(OrderId, CustomerName, Money)` uses your types because the only things that read it are in the same module. The moment something outside that module reads it, that stops being true. Now the record is a published schema, and every field is a promise. What changes between the two, in the example shop. The domain event speaks Ordering's language; the contract speaks plain types, because it outlives any one version of Ordering: ```mermaid flowchart LR subgraph inside ["inside Ordering, free to change"] Event["OrderPlaced: OrderId, Address, the lines, Money"] end Publish["PublishOrderPlaced"] subgraph published ["published, versioned, a promise"] Contract["OrderPlacedV1: OrderId, City, PostalCode, the lines, a decimal and a currency"] end Event --> Publish --> Contract Contract --> Inventory["Inventory"] Contract --> Payments["Payments"] ```
Show the code: the domain event, the contract and the class between them ```csharp // Ordering's own event, with Ordering's own types [DomainEventName("ordering.order-placed")] public sealed record OrderPlaced(OrderId OrderId, Address ShipTo, IReadOnlyList Lines, Money Total) : DomainEvent, INotification { public sealed record Line(string Sku, int Quantity); } // what the other modules get, in Ordering's contracts project [IntegrationEvent("ordering.order-placed", Version = 1)] public sealed record OrderPlacedV1( OrderId OrderId, string City, string PostalCode, IReadOnlyList Lines, decimal Total, string Currency); // the one place that knows both public sealed class PublishOrderPlaced : IOutboundIntegrationEvent { public ValueTask CreateAsync(OrderPlaced placed, CancellationToken cancellationToken) => new(new OrderPlacedV1( placed.OrderId, placed.ShipTo.City, placed.ShipTo.PostalCode, [.. placed.Lines.Select(line => new OrderedLineV1(line.Sku, line.Quantity))], placed.Total.Amount, placed.Total.Currency)); } ``` *[`Ordering/Application/Orders/IntegrationEvents/Outbound/PublishOrderPlaced.cs`](https://github.com/DylanSnel/DDDToolkit/blob/main/Examples/Modules/Ordering/DDDToolkit.Examples.Ordering/Application/Orders/IntegrationEvents/Outbound/PublishOrderPlaced.cs)*
Note the boundary in the first row. It is the **module**, not the process. A record that only another assembly in the same solution deserializes is already a published schema, because you cannot change it without changing them. That is why an integration event is part of the module's published contract: the short list of types other modules may use, where everything else stays the module's own to change. `[IntegrationEvent]` is enough to put a type on that list. Why a module publishes anything at all, what else belongs on the list, and why the example shop keeps it in a `*.Contracts` project of its own is on [Module contracts](https://dylansnel.github.io/DDDToolkit/docs/module-contracts.md). ## Saying what gets published The domain event is Ordering's own, written in Ordering's types: ```csharp public sealed record OrderPlaced(OrderId OrderId, CustomerName Customer, Money Total) : DomainEvent; ``` The contract is what the other modules read, written in primitives: ```csharp [IntegrationEvent] public sealed record OrderPlacedV2(Guid OrderId, string Customer, decimal Total, string Currency); ``` `[IntegrationEvent]` marks the type as a contract. Its name and version come from the module and the class name, so in `[assembly: Module("Ordering")]` this is published as `ordering.order-placed` version 2, the same name its domain event is stored under. Pin the name in the attribute only when the convention would give the wrong one, typically after renaming the class: `[IntegrationEvent("ordering.order-placed")]`. See [Stable names](https://dylansnel.github.io/DDDToolkit/docs/domain-events.md#stable-names) for the whole rule, and [Versioning and upcasting](https://dylansnel.github.io/DDDToolkit/docs/integration-events.md#versioning-and-upcasting) for what the version is for. The outbox does not publish the contract until you say how one becomes the other. By default, nothing is mapped and the domain event is published as it stands, under the name the outbox stored, reusing the JSON already in the row. No second type, no second serialization, no configuration. If you are not ready to split the two yet, you pay nothing for the seam being there. When you are ready, register a conversion: ```csharp options.UseOutbox(outbox => { outbox.RegisterEventsFromAssemblyContaining(); outbox.PublishAs(e => new OrderPlacedV2( e.OrderId.Value, e.Customer.Value, e.Total.Amount, e.Total.Currency)); }); ``` Now `OrderPlaced` can grow a field, lose a field or be renamed, and the wire is untouched until you change `OrderPlacedV2` on purpose. What the conversion makes is what every sink is handed; the sink for the other modules in this process comes [below](https://dylansnel.github.io/DDDToolkit/docs/integration-events.md#the-common-case-another-module-in-this-process). Two more things that map: ```csharp // This event is nobody else's business. Local handlers still get it; no sink is called. outbox.DoNotPublish(); // Publish only some occurrences. Returning null drops that one message. outbox.PublishAs(e => e.Total.Amount < 1000 ? null : Map(e)); ``` ### A class per published event A lambda is fine for one line. It stops being fine when a module publishes five events and its registration turns into the place where every contract is assembled, and it cannot grow: it has no services and it cannot await. The translation is application code, and it belongs next to the aggregate it publishes for, not in the composition root. So the same seam also takes a class, the outbound counterpart of the `IIntegrationEventHandler` a consuming module writes ([further down](https://dylansnel.github.io/DDDToolkit/docs/integration-events.md#the-common-case-another-module-in-this-process)): ```csharp public sealed class PublishOrderPlaced : IOutboundIntegrationEvent { public ValueTask CreateAsync(OrderPlaced placed, CancellationToken cancellationToken) => new(new OrderPlacedV2(placed.OrderId.Value, placed.Customer.Value, placed.Total.Amount, placed.Total.Currency)); } ``` ```csharp options.UseOutbox(outbox => { outbox.AddOrderingIntegrationEvents(); // generated: every domain event and every IOutboundIntegrationEvent in the module }); ``` `AddOrderingIntegrationEvents()` is written by the compiler when the module builds, and is named after the module. It takes the place of both the `RegisterEventsFromAssemblyContaining` line and the `PublishAs` lambda; [Registered when the module compiles](https://dylansnel.github.io/DDDToolkit/docs/integration-events.md#registered-when-the-module-compiles) shows what is in it. A class may implement the interface more than once to publish several events. Everything else is as for `PublishAs`: returning `null` drops that occurrence, a domain event has one entry however it was registered (a second one throws at start-up), and events with no entry are published as they stand. The class is built with `new` once per message, each constructor parameter taken from the scope the outbox processor runs in. Throwing from it fails the delivery the way a failing sink does: the error is recorded on the row and the message is retried. Name it for what it does, like a handler: `PublishOrderPlaced` next to `BookShipment` and `RecordPayment`. Avoid "Publisher", because it sends nothing. The sink does that. ### Where the mapping happens The outbox row always stores the domain event. The conversion runs at delivery, not at save. That is a deliberate trade. It keeps the row a faithful record of what actually happened in the domain, it means the in-process path and the sink path read the same row, and it means fixing a wrong mapping is a deployment rather than a data migration: reset `Attempts` and the rows go out again in the new shape. The cost is that the domain event type must still exist and still deserialize when the processor runs, which is what [Versioning and upcasting](https://dylansnel.github.io/DDDToolkit/docs/integration-events.md#versioning-and-upcasting) is about. ## The common case: another module in this process Integration events exist so that another module can pick something up. In a modular monolith that module is in the same process, which makes this the most valuable sink in the package and the one to reach for first. One message, from the save in Ordering to the handler in Billing: ```mermaid sequenceDiagram participant Save as Ordering's save participant Outbox as Ordering's outbox participant Processor as Outbox processor participant Publish as PublishOrderPlaced participant Sink as Module sink participant Inbox as Billing's inbox participant Handler as RaiseInvoice Save->>Outbox: the order and an OrderPlaced row, one transaction Processor->>Outbox: read the row, after the commit Processor->>Publish: OrderPlaced, the domain event Publish-->>Processor: OrderPlacedV2, the contract Processor->>Sink: the contract, in its envelope Sink->>Inbox: offered to every module that handles OrderPlacedV2 alt billing.invoicer applied this message before Inbox-->>Sink: skipped else the first time Inbox->>Handler: HandleAsync(contract, message) Handler-->>Inbox: an invoice, added to Billing's context Inbox->>Inbox: the invoice and an inbox row, one transaction end Sink-->>Processor: delivered Processor->>Outbox: set ProcessedAt ``` A handler that fails fails the delivery, and the processor tries again later. Every module is offered the message again, and the inboxes skip what they already applied, which is what makes at-least-once delivery safe.
Show the code: both modules' registration Ordering publishes, through its outbox, to the modules in this process: ```csharp // inside AddOrderingModule services.AddDDDToolkitEntityFramework(options => options.UseOutbox(outbox => { outbox.AddOrderingIntegrationEvents(); // generated: its domain events and its outbound classes outbox.SendToModules(); })); services.AddOutboxBackgroundService(TimeSpan.FromSeconds(2)); ``` One class says what the domain event becomes for the others: ```csharp public sealed class PublishOrderPlaced : IOutboundIntegrationEvent { public ValueTask CreateAsync(OrderPlaced placed, CancellationToken cancellationToken) => new(new OrderPlacedV2(placed.OrderId.Value, placed.Customer.Value, placed.Total.Amount, placed.Total.Currency)); } ``` Billing handles the contract, and signs up with the contracts it reads and the handlers that run under its inbox: ```csharp [IntegrationEventConsumer("billing.invoicer")] public sealed class RaiseInvoice(BillingContext context) : IIntegrationEventHandler { public Task HandleAsync(OrderPlacedV2 contract, IntegrationEventMessage message, CancellationToken cancellationToken) { context.Invoices.Add(new Invoice(contract.OrderId, contract.Total)); return Task.CompletedTask; } } // inside AddBillingModule services.AddDDDToolkitEntityFramework(options => options.MapIntegrationEvents(contracts => contracts.AddBillingIntegrationEvents())); // generated services.AddModuleIntegrationEvents(module => module.AddBillingIntegrationEvents()); // generated ``` Both contexts map their tables: `modelBuilder.AddDomainEventOutbox(Database)` in Ordering's, `modelBuilder.AddDomainEventInbox(Database)` in Billing's. The rest of this section goes through each part.
Each module registers its own half, next to its own context. The producing module says what it publishes and that it goes to the other modules: ```csharp // inside AddOrderingModule services.AddDDDToolkitEntityFramework(options => options.UseOutbox(outbox => { outbox.AddOrderingIntegrationEvents(); // generated: its domain events and its outbound classes outbox.SendToModules(); })); services.AddOutboxBackgroundService(TimeSpan.FromSeconds(2)); ``` `SendToModules()` is the sink: it offers each message the outbox delivers to the modules in this process that asked for it. The background service is the processor that reads the outbox rows after the commit and hands them to the sink; see [Running the processor](https://dylansnel.github.io/DDDToolkit/docs/event-delivery.md#running-the-processor). A consuming module writes a handler for the contract: ```csharp [IntegrationEventConsumer("billing.invoicer")] public sealed class RaiseInvoice(BillingContext context) : IIntegrationEventHandler { public Task HandleAsync(OrderPlacedV2 contract, IntegrationEventMessage message, CancellationToken cancellationToken) { context.Invoices.Add(new Invoice(contract.OrderId, contract.Total)); return Task.CompletedTask; } } ``` `[IntegrationEventConsumer]` names the handler for the inbox, which remembers that `billing.invoicer` has applied a message; [further down](https://dylansnel.github.io/DDDToolkit/docs/integration-events.md#each-handler-has-its-own-inbox-row) is why the name matters. `message` is the envelope the contract arrived in, with its id, its published name and when the thing happened; [Transports](https://dylansnel.github.io/DDDToolkit/docs/transports.md#why-an-envelope-and-not-the-event) lists what it carries. The consuming module then says which contracts it reads and which handlers run under its inbox: ```csharp // inside AddBillingModule services.AddDDDToolkitEntityFramework(options => options.MapIntegrationEvents(contracts => contracts.AddBillingIntegrationEvents())); // generated services.AddModuleIntegrationEvents(module => module.AddBillingIntegrationEvents()); // generated ``` `MapIntegrationEvents` registers the contracts Billing reads, so that a delivered payload can be turned back into an `OrderPlacedV2`. `AddModuleIntegrationEvents` signs Billing up as a consumer: its handlers, and the inbox in `BillingContext` that guards them. The `Add{Module}IntegrationEvents()` methods are written by a source generator when the module compiles, so nothing is found, read or created by reflection when it runs; see [Registered when the module compiles](https://dylansnel.github.io/DDDToolkit/docs/integration-events.md#registered-when-the-module-compiles). Neither module names the other. `SendToModules()` offers every message to every module registered with `AddModuleIntegrationEvents`, and each runs only its own handlers, under its own inbox, in its own context. A third module that wants `OrderPlacedV2` registers itself the same way, and nothing in Ordering or Billing changes. The host only calls `AddOrderingModule` and `AddBillingModule`; see [the example](https://github.com/DylanSnel/DDDToolkit/tree/main/Examples/ModularMonolith.Supabase). `AddDDDToolkitEntityFramework` can be called by every module like this because each call configures the same options. What is genuinely process-wide, such as `DispatchWithMediator()`, stays in the host, and setting the dispatch delegate twice throws rather than letting one module silently replace another's. Three things in that handler are worth reading twice. ### It is typed on the contract, not on the domain event `IIntegrationEventHandler`, never `IIntegrationEventHandler`. A handler typed on `OrderPlaced` forces the billing module to reference the ordering module's domain assembly, which means `OrderId`, `Money`, and whatever else that record touches. Ordering can no longer rename a field without breaking a build somewhere else, and the two modules are one module with extra folders. `OrderPlacedV2` is a record of primitives that both modules can see. Ordering owns it and publishes it, billing reads it, and neither knows anything else about the other. The payload is read from `message.Payload` and deserialized into the consumer's own object whenever the contract is registered, which is exactly what would happen if the message had come off a queue. That is deliberate: a handler cannot accidentally mutate an object the producer is still holding, and a module that is extracted later behaves the same way on its first day out. When nothing is registered under the message's name, the sink falls back to `message.Body`, which is the object the outbox built. ### It goes through the outbox, and that is what makes it different from a method call A direct call from ordering into billing runs inside ordering's transaction. Billing throws, ordering's order is rolled back, and you have coupled the two in the worst possible way: an unrelated module's bug can now refuse an order. The outbox breaks that. Ordering commits, the row is durable, and the consumers run afterwards on the background service's next poll. Whether billing succeeds is billing's problem and a later retry. ### Each handler has its own inbox row Delivery is at-least-once, so the message will be replayed. Every handler runs inside the inbox, keyed by the message id and that handler's consumer name, so its writes and the row that says "applied" are written by one `SaveChanges` inside one transaction. The consequence is what you want on a retry: | | First run | Retry | |---|---|---| | `billing.invoicer` | applied, row written | skipped | | `search.indexer` | threw, nothing written | runs again | The message as a whole counts as failed while any handler is failing, so the outbox row stays pending and is picked up again. Handlers that already succeeded are not run twice. `[IntegrationEventConsumer("billing.invoicer")]` is what the inbox keys on. Without it the name falls back to the full CLR type name, which changes when you rename or move the class, and the inbox cannot tell that from a new consumer: it replays the whole backlog through it. Name your consumers, the same way you name your events. ### What it needs in the model The inbox table, in the consuming context: ```csharp protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.AddDomainEventInbox(Database); } ``` A module that publishes as well as consumes maps its outbox next to it, with `modelBuilder.AddDomainEventOutbox(Database)`. `AddModuleIntegrationEvents(...)` registers the module, its inbox and its handlers together. Handlers should write through that same `TContext`, because that is the context whose transaction the inbox row is in. Two handlers under one consumer name in one module are refused at registration: the inbox could not tell them apart, and the second would never run. A failure names the module as well as the consumer, `BillingContext/billing.invoicer`, and a second module failing does not make the first run again: each module's inbox remembers what that module applied. Delivering needs no reflection. `Handle()` captures a typed call to the handler when it is registered, and the sink uses that. ### What it does not do It does not give a consumer its own retry schedule. One outbox row is one message, so a consumer that keeps failing keeps the row pending until `MaxAttempts`, and then the row stops being picked up and its `LastError` is there to be read. If one consumer needs a different retry policy from the others, give it its own outbox table and its own processor. It does not order anything. See [the guarantees](https://dylansnel.github.io/DDDToolkit/docs/integration-events.md#the-guarantees-honestly). ## Registered when the module compiles `DDDToolkit.EntityFramework.Analyzers` writes a module's integration event registration as code, one `Add{Module}IntegrationEvents()` for each of the three places that need it, in the namespace `{assembly}.IntegrationEvents`. `{Module}` is the project's ``, or its assembly name without the dots; [Store it with Entity Framework](https://dylansnel.github.io/DDDToolkit/docs/getting-started.md#store-it-with-entity-framework) introduces the setting. Ordering, in the example on this page, declares one domain event, `OrderPlaced`, and one outbound class, `PublishOrderPlaced`. Its build writes the registration for the outbox: ```csharp title="IntegrationEventExtensions.g.cs, shortened" namespace Ordering.IntegrationEvents; public static class IntegrationEventExtensions { public static OutboxOptions AddOrderingIntegrationEvents(this OutboxOptions outbox) { ArgumentNullException.ThrowIfNull(outbox); outbox.RegisterEvent("ordering.order-placed", 1); outbox.PublishWith("ordering.order-placed", 2, static services => new PublishOrderPlaced()); return outbox; } // ... the two overloads for the contract registry and the module's handlers, which register // nothing: Ordering has no handler } ``` Billing declares one handler, `RaiseInvoice`. Its build writes the other two: ```csharp title="IntegrationEventExtensions.g.cs, shortened" namespace Billing.IntegrationEvents; public static class IntegrationEventExtensions { // ... the overload for the outbox, which registers nothing: Billing raises no domain event public static IntegrationEventContractRegistry AddBillingIntegrationEvents(this IntegrationEventContractRegistry contracts) { ArgumentNullException.ThrowIfNull(contracts); contracts.Register("ordering.order-placed", 2); return contracts; } public static ModuleIntegrationEvents AddBillingIntegrationEvents(this ModuleIntegrationEvents module) where TContext : DbContext { ArgumentNullException.ThrowIfNull(module); module.Handle("ordering.order-placed", "billing.invoicer", static services => new RaiseInvoice(ServiceProviderServiceExtensions.GetRequiredService(services))); return module; } } ``` Line by line, that is everything the two modules register: - `RegisterEvent("ordering.order-placed", 1)` puts the domain event in the outbox's map from stored name to type, which the processor reads a row back through. The name is the event's `[DomainEventName]`, and `OrderPlaced` has none, so it is the module and the class name; the version is its `[IntegrationEvent(Version = n)]`, otherwise the one its class name ends in, otherwise 1. Every concrete domain event declared in the project is listed, whether it leaves the module or not, because the outbox stores all of them. - `PublishWith(...)` is the entry `PublishAs` would have made, with a class instead of a lambda: the contract's published name and version, read off its class name and `[IntegrationEvent]`, and the code that builds `PublishOrderPlaced` for each message. The published name is also how this process knows it publishes `ordering.order-placed` itself, so a [transport](https://dylansnel.github.io/DDDToolkit/docs/transports.md) does not ask a broker for it. - `contracts.Register("ordering.order-placed", 2)` tells the contract registry which type a payload of that name and version is read as. The module sink and the inbox read through it. Only the contracts this module's handlers take are listed, not the ones it publishes. - `module.Handle(...)` does three things. It registers `RaiseInvoice` in the container as scoped, built by the lambda. It adds the handler to Billing's consumers under `billing.invoicer`, the name its inbox rows carry. And it records that this process handles `ordering.order-placed`, which is what a transport subscribes to. `BillingContext` in that lambda comes from the handler's constructor. The constructor with the most parameters is the one called, and each parameter is taken from the scope the message is delivered in: `GetRequiredService` for an ordinary parameter, `GetService` for a reference type with a default value, and the scope itself for an `IServiceProvider`. Everything the run-time registration would read off attributes is read by the compiler instead and written out as literals: the stored name and version of every domain event and the published name and version of every contract (the convention, `[DomainEventName]`, `[IntegrationEvent]`), the consumer name of every handler (`[IntegrationEventConsumer]`, otherwise the class's full name). The outbox and the processor then look names up in the registries rather than asking a type. Nothing is scanned, read or activated by reflection; add a handler class and the next build registers it. A class the registration cannot build with `new` is left out and reported as a warning, DDD00033: one with no constructor the module can call, one whose two longest constructors are equally long, one whose parameter types the module cannot see, one with `ref`, `out` or `params` parameters. The reflection-based registrations (`RegisterEventsFromAssemblyContaining`, `RegisterFromAssemblyContaining`, `Handle()`) work for code without the generator. ## Enriching a contract, and what not to read Because a class can take services, it can add something the domain event does not carry, such as reading a product's name from the module's own read model. Be careful what you read, because of when it runs. The processor gets to the row later than the event happened, and a retry runs the class again. A query there reads the state *now*, not the state when the event was raised. If one sink accepted the first attempt and another failed, the retry can even hand the second sink a different payload under the same message id, which the first consumer's inbox then ignores. | What the contract needs | Where it comes from | |---|---| | Already in the domain event | Translate it. This is almost always the answer. | | Stable or cosmetic data from this module (a name, a category) | A read from the module's own context in the class is fine. | | Data that has to be right as of the event (a price, an address, a status) | **The domain event.** Add it there, not in the class. | | Data owned by another module | Neither. Keep a read model of it in this module, fed by that module's events. | The class reads and never writes. With [`DeliverInTransaction`](https://dylansnel.github.io/DDDToolkit/docs/transports.md#when-a-module-becomes-its-own-deployable-pgmq) a write would commit together with the mark that says the message went out. ## There is one way out, and a "no" takes it too An integration event is always a translation of a domain event, and a domain event only reaches the outbox when an aggregate raised it: the save collects them from the tracked aggregates and writes them in the same transaction as the change. That is the whole guarantee, and nothing publishes around it. A domain service decides and an aggregate records the decision. A handler that decides calls a method on an aggregate. Neither publishes anything itself, because a message sent outside the save can go out for a change that rolled back, or be lost for one that committed. That includes the answer no. "Not enough stock" or "payment declined" is an outcome other modules act on, so it is recorded like any other. The shop's `StockReservation.Refused(...)` stores a refused reservation and raises `StockRefused`, and `PublishStockRefused` translates it. Recording it also makes a retried message harmless: the second attempt finds the decision already made instead of deciding again, and perhaps differently. Not every "no" is an outcome. On the way in, three cases look alike and are handled differently: | The failure | Example | What to do | |---|---|---| | A rule of this module | Only euros accepted; a name is already taken | Record a refusal on an aggregate and publish it. | | The contract carries something invalid | A negative total, an empty currency | The producer has a bug. Throw, with the reasons: the message is retried, stops at `MaxAttempts`, and waits in the table with its `LastError`. | | A race behind a unique index | Two consumers create the same name at once | Let the `DbUpdateException` throw. On the retry the explicit check sees the other row and takes the refusal branch. | For the second row, turn the contract into your value objects with `TryToValid` rather than `ToValid()`, so the exception says which field was wrong instead of only that one was: ```csharp if (!new Money(contract.Total, contract.Currency).TryToValid(out var total, out var errors)) { throw new InvalidOperationException($"{message.Name} {message.MessageId} carries an invalid total: {string.Join("; ", errors.Select(e => e.Message))}"); } ``` A synchronous caller is different: an invalid request changed nothing and nobody else needs to hear about it, so it gets a `ValidationProblem` and nothing is published. ## Where the classes live In the example shop each module keeps both directions next to the aggregate or read model they belong to, split by kind and by direction: ``` Application/ Orders/ DomainEvents/ OrderLog (this module's own events, in process) IntegrationEvents/ Inbound/ RecordPayment, CancelWithoutStock, ... Outbound/ PublishOrderPlaced, PublishOrderConfirmed, ... ReadModels/ CatalogPrices/ CatalogPrice.cs IntegrationEvents/ Inbound/ RecordListedPrice, RecordChangedPrice ``` Every outbound class has an owner, because every domain event is raised by an aggregate. An inbound one belongs to the aggregate or read model it changes. The overview of what a module promises the others is its `*.Contracts` project; see [Module contracts](https://dylansnel.github.io/DDDToolkit/docs/module-contracts.md#a-project-of-its-own). ## The inbox on the other side Everything above is at-least-once. A consumer will eventually see the same message twice. The inbox is how you make that harmless. `SendToModules` uses it for you; this is what to do when a message arrives from somewhere else. ```csharp protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.AddDomainEventInbox(Database); } ``` ```csharp builder.Services.AddDomainEventInbox(); ``` ```csharp public sealed class OrderPlacedConsumer(DomainEventInbox inbox, BillingContext context) { public async Task Consume(IntegrationEventMessage message, CancellationToken cancellationToken) { await inbox.ExecuteOnceAsync(message, "billing.invoicer", (order, received, token) => { context.Invoices.Add(new Invoice(order.OrderId, order.Total)); return Task.CompletedTask; }, cancellationToken); // Acknowledge the message either way. A repeat is not an error. } } ``` `ExecuteOnceAsync` returns `true` when your handler ran and `false` when this consumer had already applied the message. Both are success. What it does, in order: 1. Read the payload as the shape you asked for, applying any upcasters. 2. Look for a row keyed on this message and this consumer. If it exists, return `false` and do nothing. 3. Start a transaction, unless the caller already has one, in which case join it. 4. Add the inbox row. 5. Run your handler. 6. `SaveChanges`, which writes the handler's changes and the row together. 7. Commit, unless the transaction is the caller's. Steps 4 to 7 are the whole point. The row that says "applied" and the effect of applying it are written by one `SaveChanges` inside one transaction, so there is no instant where one exists without the other. A crash anywhere in between rolls back both, and the next delivery applies the message cleanly. When a step fails, the transaction is rolled back and so is the change tracker. Whatever the attempt started tracking, the inbox row included, is detached. Without that, the next save on the same context would write the failed handler's changes after all, without its row, and the retry would apply them a second time. The module sink runs every handler of a module on one context, so the next save is usually the next consumer's. Entities the context was already tracking before the attempt are left alone. ```mermaid sequenceDiagram participant Sink as Module sink participant Inbox as Billing's inbox participant Context as BillingContext participant Db as Billing's database Sink->>Inbox: billing.invoicer Inbox->>Context: an inbox row and an invoice Note over Inbox: the invoicer throws Inbox->>Db: roll back Inbox->>Context: detach both Sink->>Inbox: billing.ledger Inbox->>Db: the ledger's entry and row only Note over Sink,Db: same context: the invoice stays out. The retry runs the invoicer again. ```
Show the code: two consumers in one module Two handlers of the same contract, each under a consumer name of its own. They run one after the other, on the one `BillingContext` of the delivery's scope: ```csharp services.AddModuleIntegrationEvents(module => module .Handle() // [IntegrationEventConsumer("billing.invoicer")] .Handle()); // [IntegrationEventConsumer("billing.ledger")] ```
The key is the pair, not the message. Two consumers of the same message each get a row and each run once, so adding a consumer later does not mean replaying its backlog through the consumers that are already up to date. Pick consumer names you will not want to change, the same way you pick `[DomainEventName]`. There are overloads that hand you the raw envelope, or just the message id, when you would rather deserialize yourself. ### What the inbox cannot do It cannot undo work outside the database. If your handler sends a mail and the transaction then rolls back, the row is gone but the mail is sent. The fix is the same shape as the outbox itself: write a row the transaction owns, and let something else act on that row afterwards. For the same reason it cannot stop two copies of a message from both running at the same moment. A broker that delivers in parallel can hand two copies to two handlers before either has saved; both find no inbox row and both run. One save then inserts the inbox row, and the other fails on it and rolls back everything it wrote, so the database ends up with one effect. Anything the losing copy did outside that transaction, a counter in memory or a call to another system, happened twice. Only what a handler does through its module's context is exactly-once. The losing copy is not reported as a failure. Once its transaction is rolled back, the inbox looks for the row again. If the winner's row is there, it returns `false`, as it would for any other repeat. It does not matter whether the loser failed on the inbox row itself or on an aggregate the winner changed first. The broker then sees a message that was handled, not one to retry. Inside a caller's transaction the inbox throws instead, because only the caller can roll that back; their retry then finds the row. ```mermaid sequenceDiagram participant A as Copy A participant B as Copy B participant Db as Billing's database A->>Db: an inbox row for this message and billing.invoicer? Db-->>A: none B->>Db: an inbox row for this message and billing.invoicer? Db-->>B: none Note over A,B: both run the handler, each in a transaction of its own A->>Db: the invoice and the inbox row, commit Note over A: true: this copy applied it B->>Db: the invoice and the inbox row Db-->>B: refused: the row is taken, or the aggregate moved on B->>Db: roll back B->>Db: an inbox row for this message and billing.invoicer? Db-->>B: copy A's Note over B: false: a repeat, acknowledged like any other ```
Show the code: what a consumer sees Nothing changes in the consumer. Both answers are success, so it acknowledges the message either way: ```csharp var applied = await inbox.ExecuteOnceAsync(message, "billing.invoicer", (order, received, token) => { context.Invoices.Add(new Invoice(order.OrderId, order.Total)); return Task.CompletedTask; }, cancellationToken); // true: this copy applied the message. // false: it had been applied already, before this copy arrived or while it ran. ```
It also does not order anything. If message B arrives before message A, the inbox applies B. Handlers that care about order have to say so themselves, usually with a version or a sequence number in the payload. ## Versioning and upcasting A name stays put while the class moves, and `[DomainEventName]` keeps it while the class is renamed. Nothing kept the **shape** stable, and the shape is the harder promise. Once the outbox has published a payload, somebody has stored it, queued it, or is about to read it back. A payload written before a deployment has to stay readable after it. Two places have to hold, and the toolkit covers both. ### The outbox reading its own old rows Every outbox row records the shape it was written in, in a `Version` column: `[IntegrationEvent(Version = n)]` on the event type, otherwise the version its class name ends in. An event that never changed shape says nothing and is version 1. When the processor reads a row whose version matches the type registered under that name, which is every row until you bump something, nothing changes. When it does not match, the processor reads the payload as the type registered for that older version and then upcasts it. ```csharp public sealed record OrderPlacedV1(OrderId OrderId, Money Total) : DomainEvent; // ordering.order-placed, version 1 public sealed record OrderPlacedV2(OrderId OrderId, Money Total, Channel Channel) : DomainEvent; // ordering.order-placed, version 2 ``` ```csharp options.MapIntegrationEvents(contracts => contracts .UpcastFrom(v1 => new OrderPlacedV2(v1.OrderId, v1.Total, Channel.Unknown))); ``` The two classes share a name because the convention leaves the suffix out of it, and each says its version in its class name, so there is nothing to keep in step. To go on raising `OrderPlaced` rather than `OrderPlacedV2`, give it the version in the attribute instead, `[IntegrationEvent(Version = 2)]`: a class name without a suffix is version 1, and two version 1 classes under one name fail the build ([DDD00036](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00036)). If the name is pinned, pin the same name on every version. `RegisterEvent()` and `RegisterEventsFromAssembly` refuse an event whose `[DomainEventName]` and `[IntegrationEvent]` name two different names, because a stored row could then never be matched to a version. Keeping two types under one domain event name is fine. `RegisterEvent` and `RegisterEventsFromAssembly` keep the newest as the type new events are written as, and the older one is only ever read. A row whose shape nobody kept is not guessed at. The message fails, `LastError` names the version and the call that would fix it, and the row waits for a human. An outbox table created by an earlier 3.0 build may not have the `Version` column yet; see [From an earlier 3.0 build](https://dylansnel.github.io/DDDToolkit/docs/migrating-to-3.md#from-an-earlier-30-build). ### A consumer reading a message The same registry reads on the receiving side, so a handler is written against one shape and never branches on a version number: ```csharp await inbox.ExecuteOnceAsync(message, "billing.invoicer", (order, received, token) => { context.Invoices.Add(new Invoice(order.OrderId, order.Total)); return Task.CompletedTask; }); ``` A v1 message arrives, is deserialized as `OrderPlacedV1`, is upcast, and reaches the handler as `OrderPlacedV2`. The module sink does the same thing for every handler it calls, so a handler typed on the current contract keeps working when an older message turns up. ### The rules - **Bump the version when you break the payload.** Removing a field, renaming one, or changing its meaning is a break. Adding an optional one is not. - **Register the step, not the jump.** v1 to v2 and v2 to v3, and a v1 payload arrives as v3. Adding v4 later is one more line instead of a rewrite of every upcaster you have. - **An upcaster invents the fields the old payload never had.** That is unavoidable and it is the reason a version bump is a decision. Write a substitute the consumer can tell apart, such as an explicit `Unknown`, not a value that looks like real data. - **Never delete the old record.** It is the only thing that can read a payload written against it. It costs a file. - **An upcaster is a pure function.** It runs on the read path, possibly for every message in a backlog. Do not put a database call in it. The version also travels on the envelope, so a consumer that has not adopted upcasting can still branch on `message.Version` without parsing the body first. ## Which delivery wins | Configured | What the processor does | |---|---| | `DispatchInProcess` only | Calls the delegate with the domain event | | One or more `SendTo` | Publishes the message to every sink. The delegate is not called | | Both | Sinks win. The delegate is skipped | | Both, plus `AlsoDispatchInProcess = true` | Delegate first with the domain event, then the sinks with the contract | ```csharp options.UseOutbox(outbox => { outbox.SendToModules(); outbox.AlsoDispatchInProcess = true; // handlers inside this module, and the other modules }); ``` With `AlsoDispatchInProcess`, a throwing local handler fails the message before any sink sees it. That is the right order: publishing a message whose local side effect failed would be a lie. Use the delegate for handlers inside the producing module, which may legitimately see the domain event, and the module sink for everything outside it. A processor with neither a sink nor a delegate throws at construction, naming both calls. ## When one sink fails and another does not Every sink is attempted, in registration order. A sink that throws does not stop the sinks behind it. The message as a whole then counts as failed. It is not marked processed, `Attempts` goes up, and `LastError` records an `IntegrationEventDeliveryException` naming the sinks that threw. The next attempt hands the message to **all** the sinks again, including the ones that already accepted it. That is the honest consequence of one row per message. Per-sink progress would need one row per sink per message, which is a different table and a different set of failure modes, and it is not what this package does. Two sinks over the same outbox means both must tolerate a repeat. If one of your transports cannot, give it its own outbox table and its own processor, or put a queue in front of it. The module sink is the exception, and only because it keeps that bookkeeping itself: it has an inbox row per consumer in each consuming module, so its handlers do make per-consumer progress across a retry. ## The guarantees, honestly - **At-least-once, end to end.** Every hop can repeat. The outbox marks a row processed only after delivery returned, a queue redelivers what was not archived, and a retry redelivers to sinks that already accepted the message. Nothing anywhere is exactly-once on the wire. - **Two exceptions, both narrow.** With [`DeliverInTransaction`](https://dylansnel.github.io/DDDToolkit/docs/transports.md#when-a-module-becomes-its-own-deployable-pgmq) and a sink that writes to the same database, the delivery and the mark commit together, so that one hop does not repeat. And the module sink's inbox rows mean a consumer that already applied a message is skipped rather than run again. - **Idempotency is keyed on `MessageId`.** It is the domain event's `EventId`, stable across every redelivery, and it is what the inbox stores. If you write your own deduplication, key it on that and nothing else. - **The inbox remembers for as long as its rows exist.** With [retention](https://dylansnel.github.io/DDDToolkit/docs/integration-events.md#keeping-the-tables-small), a message that comes back after its row was deleted is applied again. - **Ordering is best effort.** The processor loads oldest first, but a failed message is retried after messages written later, two processors can interleave, and a queue makes its own decisions. Do not build anything on delivery order. - **Delivery is not immediate.** It happens on the next poll of the background service, not at commit. - **Nothing is lost and nothing is published for a rolled back transaction.** That part the outbox does guarantee, because the rows are written by the same transaction as the aggregate. ## Tables, schema and migrations The outbox and the inbox both live in a `ddd` schema by default, away from your domain tables. Plumbing is easier to grant, purge and ignore when it is not mixed in with the business. ```csharp modelBuilder.AddDomainEventOutbox(Database); // ddd.OutboxMessages modelBuilder.AddDomainEventInbox(Database); // ddd.InboxMessages modelBuilder.AddDomainEventInbox(Database, "Consumed", schema: "msg"); // msg.Consumed modelBuilder.AddDomainEventInbox(Database, schema: null); // the provider's default schema ``` SQLite has no schemas. Its provider drops the schema when it writes an identifier, so the tables are plain `OutboxMessages` and `InboxMessages` there and everything works unchanged. Nothing to configure and nothing to work around. [pgmq](https://dylansnel.github.io/DDDToolkit/docs/transports.md#when-a-module-becomes-its-own-deployable-pgmq) creates its own tables in its own `pgmq` schema. It is not part of your model and you do not migrate it. The inbox table is four columns: | Column | Type | Meaning | |---|---|---| | `MessageId` | `Guid`, key | The message's id | | `Consumer` | `string`, key, 256 | Who applied it | | `MessageName` | `string?`, 256 | The published name, for when you are reading the table by hand | | `ProcessedAt` | `DateTimeOffset` | When the handler finished | If you scaffold migrations there is nothing else to do. Both tables are part of your model as soon as the calls are in `OnModelCreating`, so `dotnet ef migrations add` writes them, schema included. If you write migrations by hand: ```csharp public partial class AddMessaging : Migration { protected override void Up(MigrationBuilder migrationBuilder) { migrationBuilder.CreateDomainEventOutbox(); migrationBuilder.CreateDomainEventInbox(); } protected override void Down(MigrationBuilder migrationBuilder) { migrationBuilder.DropDomainEventInbox(); migrationBuilder.DropDomainEventOutbox(); } } ``` They take the same `tableName` and `schema` you gave the model builder, so pass the same arguments in both places. They create the schema first where the provider has schemas, and leave the schema off entirely where it does not. The shapes are checked against the model in the tests, so a hand-written migration and a scaffolded one produce the same table. ## Keeping the tables small Neither table shrinks by itself. The outbox marks a row delivered and leaves it there. The inbox writes a row for every message every consumer applies. Retention deletes those rows once they are older than you want to keep them: ```csharp services.AddDomainEventRetention(retention => { retention.KeepOutboxFor = TimeSpan.FromDays(7); retention.KeepInboxFor = TimeSpan.FromDays(30); }); ``` That registers `DomainEventRetention` and a background service that runs it at start and then every `Interval`, an hour by default. What one run deletes, and what it leaves: ```mermaid flowchart LR Run["DomainEventRetention, at start and every Interval"] Run --> Outbox["OutboxMessages"] Run --> Inbox["InboxMessages"] Outbox -->|"delivered longer ago than KeepOutboxFor"| Gone["deleted, BatchSize rows per statement"] Inbox -->|"applied longer ago than KeepInboxFor"| Gone Outbox -.->|"delivered recently, still waiting, or out of attempts"| Kept["kept"] Inbox -.->|"applied recently"| Kept ``` Each run deletes with `ExecuteDelete`, in the database and past the change tracker, `BatchSize` rows per statement (1000 by default). A large backlog therefore goes in many short transactions, not one long one. Both windows count from `ProcessedAt`, which both tables already index. Leave a window unset and that table is not touched. A context with only an inbox sets only `KeepInboxFor`. Only delivered outbox rows are deleted. A row still waiting, or one that ran out of attempts, stays however old it is, because it is not history yet; it is work somebody still has to look at.
Show the code: what the example shop keeps, and running it yourself Every module registers its own retention, next to its own outbox. Ordering has both tables, Catalog only publishes and Shipping only consumes, so those two set one window each: ```csharp // inside AddOrderingModule services.AddDomainEventRetention(retention => { retention.KeepOutboxFor = TimeSpan.FromDays(7); retention.KeepInboxFor = TimeSpan.FromDays(30); }); // inside AddCatalogModule: an outbox and no inbox services.AddDomainEventRetention(retention => retention.KeepOutboxFor = TimeSpan.FromDays(7)); // inside AddShippingModule: an inbox and no outbox services.AddDomainEventRetention(retention => retention.KeepInboxFor = TimeSpan.FromDays(30)); ``` To run it from a scheduler of your own instead, skip the registration and call it directly: ```csharp var retention = new DomainEventRetention(context, new DomainEventRetentionOptions { KeepInboxFor = TimeSpan.FromDays(30), }); await retention.DeleteExpiredAsync(cancellationToken); // by the windows await retention.DeleteDeliveredOutboxMessagesAsync(DateTimeOffset.UtcNow.AddDays(-7), cancellationToken); // or by a cutoff ```
### How long to keep the inbox The inbox window needs more thought than the outbox's. An inbox row is what makes a repeat a repeat, and once it is deleted, the same message delivered again is applied again: ```mermaid sequenceDiagram participant Broker participant Inbox as Billing's inbox participant Retention Broker->>Inbox: message 42, day 0 Note over Inbox: applied, and its row written Broker->>Inbox: message 42 again, day 3 Inbox-->>Broker: a repeat, skipped Retention->>Inbox: day 31: rows older than 30 days go, message 42's with them Broker->>Inbox: message 42 again, day 40 Note over Inbox: no row any more: applied a second time ``` So keep inbox rows longer than any message can take to come back: the broker's own retention, the outbox's retries, and an operator resetting `Attempts` on a row that failed last week. Days is usually right for the outbox, and weeks for the inbox. ## Where to look next - [Transports](https://dylansnel.github.io/DDDToolkit/docs/transports.md) for pgmq, Wolverine, MassTransit, and writing a sink of your own. - [Module contracts](https://dylansnel.github.io/DDDToolkit/docs/module-contracts.md) for why a module publishes contracts, and where to keep them. - [Domain events](https://dylansnel.github.io/DDDToolkit/docs/domain-events.md) for raising, draining and [how an event is named](https://dylansnel.github.io/DDDToolkit/docs/domain-events.md#stable-names). - [Delivering domain events](https://dylansnel.github.io/DDDToolkit/docs/event-delivery.md) for the outbox itself, the processor, retries and `MaxAttempts`. - [GraphQL](https://dylansnel.github.io/DDDToolkit/docs/graphql.md#pushing-integration-events-to-subscribers) for the subscription sink. - `Tests/DDDToolkit.EntityFramework.Tests/ModuleIntegrationEventTests.cs` for the module sink and the per-consumer retry behaviour. - `Tests/DDDToolkit.EntityFramework.Tests/OutboundIntegrationEventTests.cs` for the outbound classes. - `Tests/DDDToolkit.Analyzers.Tests/Integrations/IntegrationEventsGeneratorTests.cs` for the generated registration and DDD00033. - `Tests/DDDToolkit.EntityFramework.Tests/EventVersioningTests.cs` for upcasting on both read paths. - `Tests/DDDToolkit.EntityFramework.Tests/IntegrationEventTests.cs` for the sink contract and the multi-sink failure behaviour. - `Tests/DDDToolkit.EntityFramework.Tests/InboxTests.cs` for the crash between handling and marking, a copy that loses the race to another copy, and the outbox and inbox working together. - `Tests/DDDToolkit.EntityFramework.Tests/RetentionTests.cs` for what retention deletes and what it keeps. - `Tests/DDDToolkit.EntityFramework.Tests/MessagingSchemaTests.cs` for the schema override and the migration helpers. # Transports A module that runs in the same process as the modules that react to it needs no transport: the [module sink](https://dylansnel.github.io/DDDToolkit/docs/integration-events.md#the-common-case-another-module-in-this-process) hands each message to them directly. The day a module becomes a deployable of its own, that stops working. The outbox still writes the message in the aggregate's transaction, and the receiving service's inbox still applies it once, but something has to carry the message from one process to the other. The toolkit does not write that part. There is no client here for RabbitMQ, Azure Service Bus, Kafka or SQS, and there is not going to be one. [MassTransit](https://masstransit.io/) and [Wolverine](https://wolverinefx.net/) are far more mature at that job than anything this repository would write, so the toolkit hands its messages to them ([Wolverine](https://dylansnel.github.io/DDDToolkit/docs/transports.md#through-a-broker-wolverine), [MassTransit](https://dylansnel.github.io/DDDToolkit/docs/transports.md#through-a-broker-masstransit)) and keeps only the outbox and the inbox on either side. What it does ship itself is a sink for Postgres queues ([pgmq](https://dylansnel.github.io/DDDToolkit/docs/transports.md#when-a-module-becomes-its-own-deployable-pgmq)), because there the queue is a table and the guarantees change. Whichever carries the message, both ends stay as [Integration events](https://dylansnel.github.io/DDDToolkit/docs/integration-events.md) describes them. The sending module publishes the same contract through the same outbox, with a different sink. The receiving process registers its modules with `AddModuleIntegrationEvents`, exactly as a monolith does, and each handler runs inside its module's inbox. A handler cannot tell which way a message came. The roads a message can take, from the one that needs nothing to the ones that cross a network: ```mermaid flowchart TB subgraph inproc ["In process: the module sink"] direction LR A1["Ordering's outbox"] -->|"in memory"| B1["Shipping's inbox"] end subgraph onequeue ["In process, one pgmq queue: pgmq 1.5 and later"] direction LR A2["every module's outbox"] --> Q2[("queue shop")] --> B2["every module's inbox"] end subgraph topics ["Services, pgmq topics: pgmq 1.11 and later"] direction LR A3["Storefront's outbox"] -->|"send_topic"| Q3a[("queue payments")] --> B3a["Payments' inboxes"] A3 -->|"send_topic"| Q3b[("queue fulfilment")] --> B3b["Fulfilment's inboxes"] end subgraph broker ["Services, RabbitMQ: Wolverine or MassTransit"] direction LR A4["a service's outbox"] --> R4[["RabbitMQ"]] --> B4["other services' inboxes"] end inproc ~~~ onequeue ~~~ topics ~~~ broker ```
Show the code: choosing the road Only the sink on the outbox and, away from the module sink, what reads on the other side change. The modules and their handlers stay as they are: ```csharp // one process: the module sink options.UseOutbox(outbox => outbox.SendToModules()); // one process, through one pgmq queue builder.Services.AddPgmqSink(queues, pgmq => pgmq.UseQueue("shop")); builder.Services.AddPgmqConsumer(queues, "shop"); options.UseOutbox(outbox => outbox.SendToPgmq()); // services over pgmq topics builder.Services.AddPgmqSink(queues, pgmq => pgmq.UseTopics()); builder.Services.AddPgmqConsumer(queues, "fulfilment", consumer => consumer.BindTopics = true); options.UseOutbox(outbox => outbox.SendToPgmq()); // services over RabbitMQ options.UseOutbox(outbox => outbox.SendToWolverine()); // and wolverine.ReceiveIntegrationEvents(...) options.UseOutbox(outbox => outbox.SendToMassTransit()); // and bus.AddIntegrationEventConsumers(...) ``` `queues` is an `NpgsqlDataSource` on the database the queues live in. Each section below has the whole registration for its road, and `Examples/` runs every one of them.
## When a module becomes its own deployable: pgmq `DDDToolkit.Messaging.Postgres` sends published messages to a [pgmq](https://github.com/pgmq/pgmq) queue. pgmq is a Postgres extension whose queues are ordinary tables, and `pgmq.send` is an ordinary insert. That one fact is the whole reason this package exists: the enqueue obeys the transaction it is called in. No broker can do that, which is why every broker needs an outbox in front of it. On Postgres you get the outbox guarantee from the queue itself. Supabase Queues is this extension with a UI on top. A project has it once Queues is turned on in the dashboard, or once a migration runs `create extension if not exists pgmq;`. The sink knows nothing about Supabase; it talks to Postgres through Npgsql and SQL. To have the Supabase CLI apply your migrations, see `DDDToolkit.EntityFramework.Supabase` on [Supabase](https://dylansnel.github.io/DDDToolkit/docs/supabase.md). Which pgmq a database has decides what you can use, and Supabase's is not the newest. A Supabase project gets the pgmq that goes with its Postgres version: 1.5.1 on Postgres 17 at the time of writing. Ask a project which one it offers: ```sql select default_version, installed_version from pg_available_extensions where name = 'pgmq'; ``` | | pgmq | On Supabase's 1.5.1 | |---|---|---| | Named queues: `UseQueue`, `UseQueues`, the consumer, headers | 1.5.1 and later | Yes | | [Topics](https://dylansnel.github.io/DDDToolkit/docs/transports.md#publish-and-subscribe-topics): `UseTopics`, `BindTopics` | 1.11 and later | No | Both columns are tested: the example shop runs over named queues on a real Supabase project in the Supabase Live workflow, and over topics on pgmq 1.13 in `Examples/Microservices.Pgmq`. The sink and the consumer read the installed version when the application starts, and refuse topics on a pgmq without them; see [Queues, creation and the missing extension](https://dylansnel.github.io/DDDToolkit/docs/transports.md#queues-creation-and-the-missing-extension). ```bash dotnet add package Temp.DDDToolkit.Messaging.Postgres ``` ```csharp builder.Services.AddPgmqSink(pgmq => pgmq.UseQueue("ordering_events")); builder.Services.AddDDDToolkitEntityFramework(options => { options.UseOutbox(outbox => { outbox.AddOrderingIntegrationEvents(); outbox.SendToPgmq(); outbox.DeliverInTransaction = true; }); }); ``` `PgmqSink` sends on that context's connection, and joins that context's current transaction if it has one. `DeliverInTransaction` is what makes that pay: the outbox processor then wraps one message's delivery and its "processed" mark in a single transaction. Either the message is on the queue and the row is marked, or neither happened. The handoff from the outbox to the queue is exactly once. Turn `DeliverInTransaction` on for a sink that writes to the same database, and leave it off otherwise. A send to a broker or an HTTP endpoint cannot be rolled back, so widening the transaction around it buys nothing. It also changes what `SendToModules` guarantees: the inbox joins the caller's transaction, so with one transaction around the whole message a failing consumer rolls back the consumers that already succeeded. Use one or the other on a given outbox, not both. ### Enqueueing in the aggregate's own transaction You can go further and skip the outbox, because `pgmq.send` is just an insert: ```csharp await using var transaction = await context.Database.BeginTransactionAsync(); context.Orders.Add(order); await context.SaveChangesAsync(); await PgmqQueue.SendAsync( (NpgsqlConnection)context.Database.GetDbConnection(), (NpgsqlTransaction?)context.Database.CurrentTransaction?.GetDbTransaction(), "ordering_events", JsonSerializer.Serialize(new OrderPlacedV2(order.Id.Value, order.Total.Amount))); await transaction.CommitAsync(); ``` The order and the message now commit together, with nothing in between. The outbox is still the better default. It keeps the queue name and the payload shape out of the code path that writes the aggregate, it retries for you, it lets you add a second sink without touching the aggregate, and it survives the queue being briefly unreachable. Reach for the direct call when you have a specific reason and can name it. ### Reading the queue The receiving process registers its modules exactly as a monolith does, with `AddModuleIntegrationEvents`, and a `PgmqConsumer` for its queue: ```csharp builder.Services.AddShippingModule(...); // AddModuleIntegrationEvents(m => m.AddShippingIntegrationEvents()) builder.Services.AddPgmqConsumer(dataSource, "fulfilment", consumer => consumer.BindTopics = true); ``` The consumer reads the queue, rebuilds each envelope from its headers (`IntegrationEventHeaders.ToMessage`), and hands it to `IntegrationEventReceiver`, which offers it to every module in the process the way the module sink does: each handler inside its module's inbox. So `BookShipment` is the same class whether the message came from Ordering next door or through a queue, and a message delivered twice is applied once. pgmq is at-least-once like everything else. Reading hides a message for a visibility timeout rather than removing it, and the consumer archives a message only after every module applied it. When a handler throws, the message is left alone and becomes visible again after `VisibilityTimeout`: a retry is a wait. After `MaxDeliveries` reads it is archived as poison and logged as an error, so one bad message cannot hold up the queue; it stays readable in the archive table. A message without the toolkit's headers has no identity to deduplicate on, and is archived unread. The consumer reads with a long poll. When the queue is empty, a read waits inside Postgres, with `pgmq.read_with_poll`, for up to `LongPollTimeout` (five seconds), and returns as soon as a message is committed. Postgres looks at the queue every `LongPollInterval` (100 milliseconds) while it waits, with no round trip, so a message is picked up within a tenth of a second of its commit rather than on the application's next poll, and a quiet queue costs one round trip every five seconds rather than one a second. `read_with_poll` is in pgmq 1.5.1, so this works on Supabase too. The cost is a connection. A long poll holds one from the consumer's data source for as long as it waits, which on a quiet queue is nearly all the time, so every consumer in the process keeps one connection busy. It is the default all the same, because a consumer's whole job is to wait for messages, and one connection each is a small price for hearing about them at once. Count one per consumer when you size the pool, and keep `LongPollTimeout` below any `statement_timeout` of the role the consumer connects as, or Postgres cancels the read and the consumer logs it as a failure. ```csharp consumer.LongPollTimeout = TimeSpan.FromSeconds(20); // wait longer in each read consumer.LongPollInterval = TimeSpan.FromMilliseconds(250); // look at the queue less often while waiting consumer.LongPollTimeout = TimeSpan.Zero; // no long poll: read, then wait PollingInterval ``` With long polling off, the consumer reads with `pgmq.read` and waits `PollingInterval` (one second) after an empty read, as it also does after a read that failed. `ConsumeOnceAsync` never waits, whatever the options say: it reads one batch, delivers it and returns how many messages there were, which is what a test that polls by hand needs. `PgmqQueue.ReadAsync`, `ReadWithPollAsync` and `ArchiveAsync` are there for anything the consumer does not do. `PgmqMessage.MessageId` is pgmq's own counter, not the integration message id; idempotency keys on the `messageId` header, the domain event's `EventId`, the same one the [inbox](https://dylansnel.github.io/DDDToolkit/docs/integration-events.md#the-inbox-on-the-other-side) stores. ### Publish and subscribe: topics A pgmq queue is a queue: a message read by one consumer is gone for the others. Since version 1.11 pgmq routes by topic as well, the way a RabbitMQ topic exchange does. A queue is bound to routing-key patterns (`pgmq.bind_topic`), and `pgmq.send_topic` puts a message on every queue bound to a pattern its key matches. That is pgmq's own publish and subscribe, and the way to use it between services: ```csharp // every service: send by topic, the contract's published name as the routing key builder.Services.AddPgmqSink(dataSource, pgmq => pgmq.UseTopics()); // every service: its own queue, bound to what it has to be sent builder.Services.AddPgmqConsumer(dataSource, "fulfilment", consumer => consumer.BindTopics = true); ``` The sender names no queue. Each consumer binds its own queue at start-up to every contract its modules handle and another service publishes (`IntegrationEventSubscriptions.FromElsewhere`), and unbinds the exact names it no longer handles; a wildcard somebody bound by hand is left alone. The sends ride one connection and one transaction, so a message reaches all of its queues or none. A message nobody is bound to reaches no queue, which is how a broker's exchange behaves too: a consumer that has never started has asked for nothing yet. `Examples/Microservices.Pgmq` routes the whole shop this way. Without topic routing, on a pgmq older than 1.11, `pgmq.UseQueues(message => ...)` enqueues on named queues instead, which means the sender has to know its receivers. That is the shape on Supabase today. Where the receivers are the modules of one process, it is also the simplest one: every message on one queue, read back into the modules, whose inboxes decide what each one handles. `Examples/ModularMonolith.Supabase` does that when it runs with `Messaging=pgmq`: ```csharp // every module's outbox sends to the one queue, and this host reads it back into the modules services.AddPgmqSink(queues, pgmq => pgmq.UseQueue("shop")); services.AddPgmqConsumer(queues, "shop"); var host = new ModuleHost(database, outbox => outbox.SendToPgmq()); ``` *[`ModularMonolith.Supabase/DDDToolkit.Examples.Host/Program.cs`](https://github.com/DylanSnel/DDDToolkit/blob/main/Examples/ModularMonolith.Supabase/DDDToolkit.Examples.Host/Program.cs)* No module sink is involved, so nothing reaches a module except through the queue. That makes it the step before a module moves out: its messages already travel through the database rather than a method call, so moving the module changes where it runs, not how it hears. ### What the sink sends The body is the envelope's `Payload`, stored as `jsonb`. The envelope's routing fields go into pgmq's `headers` column, so a consumer can filter without parsing the body and a human reading the table can tell what a row is: ```json { "messageId": "0199...", "name": "ordering.order-placed", "version": "2", "contentType": "application/json", "occurredAt": "2026-09-13T12:00:00.0000000+00:00", "aggregateType": "Order", "aggregateId": "ORD_0199..." } ``` Turn that off with `pgmq.SendHeaders = false`. ### Queues, creation and the missing extension By default the sink sends everything to one queue called `integration_events` and creates it on first use. `pgmq.create` is idempotent, and the create runs on a connection of its own rather than on yours, because creating a table is DDL and Postgres rolls DDL back like anything else. A queue is deployment state; it should not vanish when a business transaction changes its mind. ```csharp pgmq.UseQueue("ordering_events"); // one queue, named pgmq.UseQueue(message => message.Name.Replace('.', '_')); // one queue per event name pgmq.CreateQueueIfMissing = false; // queues come from a migration ``` pgmq builds table names from the queue name, so keep them short, lower case, and free of anything that is not a letter, a digit or an underscore. A published name like `ordering.order-placed` has to be rewritten, not passed through. Turn creation off when the application's database user may not create tables; the sink then fails on a missing queue instead of hiding it. If the extension is not installed you get a `PgmqNotInstalledException` naming the database and saying what to run, rather than `schema "pgmq" does not exist` from somewhere deep in the driver: ```sql CREATE EXTENSION IF NOT EXISTS pgmq; ``` The extension has to be on the server first. `ghcr.io/pgmq/pg17-pgmq` is an image that ships it, and managed Postgres that offers queues generally has it already. That failure comes when the application starts, not with the first message. `AddPgmqSink` and `AddPgmqConsumer` register a check that runs before any hosted service starts, the consumers and the outbox processor included. It reads the installed version once per database, however many sinks and consumers share it: ```sql select extversion from pg_extension where extname = 'pgmq'; ``` When there is no row, the start fails with `PgmqNotInstalledException`. Where the sink uses topics or a consumer binds them, a pgmq older than 1.11 fails it with `PgmqTopicsNotSupportedException`, which names the installed version and says what works instead: ```text The pgmq extension in database 'postgres' is version 1.5.1, and topics (UseTopics on the sink, BindTopics on the consumer) need pgmq 1.11 or later. Supabase ships pgmq 1.5.1 with Postgres 17 at the time of this release, so a Supabase project cannot route by topic yet. Named queues work on every version: send with UseQueue or UseQueues instead of UseTopics, and leave BindTopics off. ... ``` `PgmqQueue.InstalledVersionAsync` returns the same version for a check of your own, and `PgmqQueue.EnsureTopicRoutingAsync` throws the same two exceptions. The check needs the database when the application starts. It runs in `StartingAsync`, and the host calls that for its services in the order they were registered, unless it starts them concurrently. A migration applied before `RunAsync` is done by then. Something that installs the extension as the host starts, a hosted service running a migration with `CREATE EXTENSION` for instance, has to be registered before the sink and the consumer. Where neither fits, turn the check off on the sink and on the consumer: ```csharp builder.Services.AddPgmqSink(dataSource, pgmq => pgmq.CheckExtensionOnStart = false); builder.Services.AddPgmqConsumer(dataSource, "fulfilment", consumer => consumer.CheckExtensionOnStart = false); ``` ### Settings from configuration The sink and the consumer both take a configuration section, before the lambda or instead of it, so the settings can differ per environment without a rebuild: ```csharp builder.Services.AddPgmqSink(dataSource, builder.Configuration.GetSection("Pgmq:Sink")); builder.Services.AddPgmqConsumer(dataSource, "fulfilment", builder.Configuration.GetSection("Pgmq:Consumer"), consumer => consumer.BindTopics = true); ``` ```json { "Pgmq": { "Sink": { "Queue": "shop" }, "Consumer": { "LongPollTimeout": "00:00:10", "MaxDeliveries": 5 } } } ``` Every property of `PgmqConsumerOptions` is a key of the same name. The sink reads `Queue` for one queue, `Queues` for a list that every message goes to, `Topics` set to `true` to route by topic, and `CreateQueueIfMissing`, `SendHeaders` and `CheckExtensionOnStart`. Keys match in any case, a time span is written `00:00:05`, and a key that is not there keeps its default, so an environment variable such as `Pgmq__Consumer__LongPollTimeout` works as it does for any other setting. A queue chosen per message stays in code, with `UseQueue(message => ...)`. The section is read by hand rather than with the configuration binder, so a mistake fails when the services are registered instead of being ignored: a key the options do not know (`LongPolTimeout`), a value that does not parse, or a sink section with more than one of `Queue`, `Queues` and `Topics`. The message names the key. The lambda runs after the section, so code has the last word. For the sink that includes the routing: whichever of `UseTopics`, `UseQueues` and `UseQueue` is called last decides, whether the call came from the section or from code. `ReadFrom(section)` on either options class does the same inside a lambda of your own. ### For a queue in another database `PgmqSink` (no type argument) opens its own connections from an `NpgsqlDataSource`: ```csharp builder.Services.AddPgmqSink(NpgsqlDataSource.Create(connectionString), pgmq => pgmq.UseQueue("events")); // and on the outbox: outbox.SendToPgmq(); ``` There is no shared transaction on that path, so it is at-least-once like any other remote sink. Use it when the queue genuinely lives somewhere else. ## Through a broker: Wolverine `DDDToolkit.Messaging.Wolverine` makes Wolverine the transport between the outbox of one process and the inbox of another. Wolverine carries the message; the toolkit keeps the outbox that writes it in the aggregate's transaction and the inbox that applies it once. Wolverine's own outbox, inbox and sagas are not used, so there is one of each rather than two that disagree. Messages travel the way Wolverine sends anything: each contract is a message type of its own, and Wolverine's routing decides where it goes. With RabbitMQ's conventional routing every contract type gets a fanout exchange, and every service that handles the type a queue bound to it. What the contract does not carry travels in headers, the same ones the pgmq sink writes (`IntegrationEventHeaders`): the outbox's message id, the published name and version, the time and the aggregate. ```csharp builder.Services.AddFulfilmentModules(...); // first: the modules say what this service handles builder.UseWolverine(wolverine => { wolverine.UseRabbitMq(rabbitUri) .AutoProvision() .UseConventionalRouting(conventions => conventions .UseIntegrationEventNames() // exchanges named ordering.order-placed.v1 .QueueNameForListener(type => $"fulfilment.{type.Name}") // a queue per service and contract .ConfigureListeners((listener, _) => listener.ProcessInline()) .ConfigureSending((sender, _) => sender.SendInline())); // a handler per contract this service has to be sent; retries, then the error queue wolverine.ReceiveIntegrationEvents(builder.Services.IntegrationEventSubscriptions()); wolverine.Policies.DisableConventionalLocalRouting(); // what this process publishes goes to the broker }); // the outbox of each module options.UseOutbox(outbox => outbox.SendToWolverine()); ``` `WolverineSink` publishes the contract through `IMessageBus`. `ReceiveIntegrationEvents` adds an `IntegrationEventHandler` to Wolverine's discovery for every contract the modules handle and this process does not publish itself, so Wolverine listens for exactly those types; nothing names a contract by hand. The handler hands the message to `IntegrationEventReceiver`, which delivers it to the modules of the process, each handler inside its module's inbox. When a handler throws, Wolverine retries with a cooldown and then moves the message to its error queue; a retry cannot apply anything twice. Three things to get right. Name the listener queues per service, as above, or two services that handle one contract share a queue and each get half the messages. Listen inline (`ProcessInline()`), so a message is acknowledged after the modules applied it; a buffered listener acknowledges first, and a crash in between loses the message. And reference `WolverineFx.RuntimeCompilation` in the process, because Wolverine compiles its handler adapters at start-up and since 6.x ships that compiler separately. `Examples/Microservices.Wolverine` runs the shop this way. `UseIntegrationEventNames()` is optional, and it is what the samples do. Without it Wolverine names a contract's exchange after its CLR type, so renaming the contract class or moving it to another namespace moves its messages to another exchange, and a service still on the old name sends into the void. With it the exchange is the contract's published name and version, `ordering.order-placed.v1` ([Stable names](https://dylansnel.github.io/DDDToolkit/docs/domain-events.md#stable-names)), which stays put while the class is renamed as long as the name is pinned. Each version is an exchange of its own, because each version is a type of its own to Wolverine. Listeners bind their queues to the same name, so every service that shares the events has to switch together. Other message types keep Wolverine's names. ## Through a broker: MassTransit `DDDToolkit.Messaging.MassTransit` does the same with MassTransit: each contract a message type of its own, published and consumed as MassTransit does any message, the same receiver behind it, MassTransit's own outbox and sagas left out. It is built on **MassTransit 8**, the last major version under the Apache 2.0 licence; 9 and later are commercial, and moving to them is for whoever deploys the software to decide. ```csharp builder.Services.AddFulfilmentModules(...); // first: the modules say what this service handles builder.Services.AddMassTransit(bus => { // a consumer per contract the modules handle and another service publishes bus.AddIntegrationEventConsumers(builder.Services.IntegrationEventSubscriptions()); bus.UsingRabbitMq((context, rabbit) => { rabbit.Host(rabbitUri); rabbit.UseIntegrationEventNames(); // exchanges named ordering.order-placed.v1, before any endpoint // this service's queue; MassTransit binds it to the exchange of every contract its consumers take rabbit.ReceiveEndpoint("fulfilment", endpoint => { endpoint.UseMessageRetry(retry => retry.Intervals(250, 1000, 5000)); endpoint.ConfigureConsumers(context); }); }); }); // the outbox of each module options.UseOutbox(outbox => outbox.SendToMassTransit()); ``` `MassTransitSink` publishes the contract through `IPublishEndpoint`, so MassTransit gives it the exchange of its type, with the outbox's message id as MassTransit's message id and the toolkit's headers alongside. MassTransit names that exchange after the CLR type, `Shop.Ordering.Contracts:OrderPlacedV1`, unless `UseIntegrationEventNames()` replaces its entity name formatter with `IntegrationEventEntityNameFormatter`: then it is the published name and version, `ordering.order-placed.v1`, and survives renaming the class for the same reasons as under Wolverine above. Consumers bind through the same formatter, so switch every service together. Message types the toolkit does not name, `Fault` among them, keep MassTransit's names. `IntegrationEventConsumer` hands it to `IntegrationEventReceiver`; MassTransit acknowledges it when the consumer returns, retries it as the endpoint says when a handler throws, and then moves it to the endpoint's error queue. One thing to know: MassTransit has an `AddMediator` of its own on `IServiceCollection`. In a file that imports the `MassTransit` namespace, the [Mediator](https://github.com/martinothamar/Mediator) source generator no longer reads the options of your `AddMediator` call, and the process stops at start-up saying it generated for another lifetime. Configure MassTransit in a file of its own, as `Examples/Microservices.MassTransit` does in each service's `RabbitMq.cs`. ## For screens: the GraphQL subscription sink `DDDToolkit.HotChocolate` has one more sink, `GraphQlSubscriptionSink`, and it is a different axis from the others. The module sink, pgmq and the brokers are integration: durable, retried, and the receiver gets the message whether or not it was running at the time. A GraphQL subscription stores nothing and reaches only the clients holding a socket right now, so use it to keep a browser in step with the server, never as the path by which some other part of the system learns that an order was placed. [GraphQL](https://dylansnel.github.io/DDDToolkit/docs/graphql.md#pushing-integration-events-to-subscribers) has the registration, the subscription field, the transports, and why it publishes the contract rather than the domain event. ## Writing a sink of your own One method, one message, one cancellation token. Return and the transport accepted it. Throw and it did not. ```csharp public interface IIntegrationEventSink { Task SendAsync(IntegrationEventMessage message, CancellationToken cancellationToken = default); } ``` ```csharp using DDDToolkit.BaseTypes; using DDDToolkit.Interfaces; public sealed class WebhookSink(HttpClient client) : IIntegrationEventSink { public async Task SendAsync(IntegrationEventMessage message, CancellationToken cancellationToken) { using var content = new StringContent(message.Payload, Encoding.UTF8, message.ContentType); content.Headers.Add("X-Message-Id", message.MessageId.ToString()); content.Headers.Add("X-Message-Name", $"{message.Name}/v{message.Version}"); var response = await client.PostAsync("/events", content, cancellationToken); response.EnsureSuccessStatusCode(); } } ``` A sink for a real broker is the same shape. Topic from `Name`, deduplication id from `MessageId`, partition key from `AggregateId`, body from `Payload`: ```csharp public sealed class ServiceBusSink(ServiceBusSender sender) : IIntegrationEventSink { public Task SendAsync(IntegrationEventMessage message, CancellationToken cancellationToken) => sender.SendMessageAsync( new ServiceBusMessage(message.Payload) { MessageId = message.MessageId.ToString(), Subject = message.Name, PartitionKey = message.AggregateId, ContentType = message.ContentType, ApplicationProperties = { ["version"] = message.Version }, }, cancellationToken); } ``` `SendTo()` takes the sink from the scope the processor runs in when you registered it there, and otherwise builds it with its constructor services injected. `SendTo(sink)` takes an instance you already have, which is what tests usually want. Every sink on an outbox is handed every message the outbox publishes, and a message counts as delivered only when all of them accepted it. [When one sink fails and another does not](https://dylansnel.github.io/DDDToolkit/docs/integration-events.md#when-one-sink-fails-and-another-does-not) is what that means for a sink that cannot tolerate a repeat. ### Why an envelope and not the event The sink receives an `IntegrationEventMessage`, not an `IDomainEvent` and not the outbox row. Both of those were considered and neither is right. Handing a sink the `IDomainEvent` makes every sink responsible for serializing it, which means every sink has to know your JSON options, and it quietly puts the domain type on the wire. Handing it the `OutboxMessage` row is worse: that row carries `Attempts`, `NextAttemptAt`, `ProcessedAt` and `LastError`, which are this process's bookkeeping and no transport's business, and it drags Entity Framework into the signature. A sink project would then have to reference `DDDToolkit.EntityFramework` to send an HTTP request. The envelope is neither. It lives in the core `DDDToolkit` package, it has no Entity Framework types at all, and it carries exactly what a transport routes on: | Member | What it is | |---|---| | `MessageId` | The idempotency key. The domain event's `EventId`, which is also the outbox row's key | | `Name` | What consumers route on | | `Version` | The schema version of `Payload` | | `Payload` | The serialized body | | `ContentType` | `application/json` unless you change it | | `OccurredAt` | When the thing happened, not when delivery was attempted | | `AggregateType` | CLR type name of the aggregate it came from | | `AggregateId` | The aggregate's key as text. Useful as a partition key | | `Body` | The object `Payload` came from, for transports that speak CLR objects | One identifier runs the whole way. `EventId` on the event, `Id` on the outbox row, `MessageId` on the envelope, `MessageId` on the consumer's inbox row. That is deliberate, and it is what makes [the guarantees](https://dylansnel.github.io/DDDToolkit/docs/integration-events.md#the-guarantees-honestly) add up. ## Where to look next - [Integration events](https://dylansnel.github.io/DDDToolkit/docs/integration-events.md) for the contract, the module sink, the inbox and versioning. - `Examples/Microservices.Pgmq`, `Examples/Microservices.Wolverine` and `Examples/Microservices.MassTransit` for the example shop run as three services over each transport. - `Tests/DDDToolkit.EntityFramework.Tests/PgmqSinkTests.cs` for the transactional enqueue, against a real Postgres, and `PgmqConsumerTests.cs` next to it for reading the queue. - `Tests/DDDToolkit.EntityFramework.Tests/OutboxTransactionTests.cs` for `DeliverInTransaction`. - `Tests/DDDToolkit.Messaging.Tests/WolverineTests.cs` and `MassTransitTests.cs` for the two brokers. - `Tests/DDDToolkit.EntityFramework.Tests/IntegrationEventTests.cs` for the sink contract. # GraphQL `DDDToolkit.HotChocolate` makes a domain model built with the toolkit printable as a GraphQL schema. Without it a strongly typed id becomes an object type with a `value` field, the validation bookkeeping on your value objects becomes public API, a method on an aggregate becomes a field that any query can run, and a client has to know that an `OrderId` is a wrapper. With it an `OrderId` is a `UUID`, the bookkeeping and the methods are gone, and every domain event shares one interface. This page starts with ids, which every schema needs first, then the conventions every schema gets, errors and Relay. After that come the parts for larger systems: one schema over several modules, and pushing events to subscribed clients. How the generated bindings work is at the end. ## Install ```bash dotnet add package Temp.DDDToolkit.HotChocolate ``` The package brings its own source generator, so referencing it is the whole of the build-time setup. It depends on `HotChocolate.AspNetCore`, so you do not add HotChocolate separately. It works with HotChocolate 16.0.0 and later. The package asks for no more than that, and every pull request runs the tests against both 16.0.0 and the newest release (16.6.6 at the time of writing). ## Register Two kinds of call, both on the `IRequestExecutorBuilder`: ```csharp using DDDToolkit.HotChocolate; using Ordering.Domain.GraphQl; // generated using SharedKernel.GraphQl; // generated builder.Services .AddGraphQLServer() .AddDDDToolkitTypes() .AddSharedKernelGraphQlRuntimeBindings() .AddOrderingGraphQlRuntimeBindings() .AddQueryType(); ``` `AddDDDToolkitTypes()` registers the conventions, and there is one call for the whole schema. Calling it twice produces the same schema as calling it once. It does four things, each explained further down: - It removes `[Internal]` members, the toolkit's bookkeeping, through the `IgnoreInternalFieldsInterceptor`. See [Internal members are removed from the schema](https://dylansnel.github.io/DDDToolkit/docs/graphql.md#internal-members-are-removed-from-the-schema). - It removes the fields HotChocolate would make of a domain type's methods. See [Domain types publish their data, not their behaviour](https://dylansnel.github.io/DDDToolkit/docs/graphql.md#domain-types-publish-their-data-not-their-behaviour). - It adds the `DomainEvent` interface type. See [The DomainEvent interface](https://dylansnel.github.io/DDDToolkit/docs/graphql.md#the-domainevent-interface). - In a Fusion source schema, and only there, it marks value objects `@shareable`. See [Value objects in a Fusion source schema](https://dylansnel.github.io/DDDToolkit/docs/graphql.md#value-objects-in-a-fusion-source-schema). `Add{Module}GraphQlRuntimeBindings()` registers the scalar bindings, the type converters and the Relay node id serializers, and there is one call per assembly that declares identifiers or single value objects. The method is generated into the namespace `{AssemblyName}.GraphQl`, on a static class named `HotChocolateExtensions`. The `{Module}` part comes from the `DDD_Module` MSBuild property, so a project that sets `Ordering` gets `AddOrderingGraphQlRuntimeBindings`. See [Store it with Entity Framework](https://dylansnel.github.io/DDDToolkit/docs/getting-started.md#store-it-with-entity-framework), where the same property names the converter method. An assembly that declares neither an identifier nor a single value object gets no method at all. The order of the two is not significant: the calls only record configuration, and the schema is built afterwards. The order above reads in the direction of the dependency, from conventions to your types. ## What a typed id looks like in the schema Take an id and a field that returns it: ```csharp [EntityId("TST")] public readonly partial record struct TicketId { public static TicketId Create(Guid value) => new(value); } ``` ```csharp public sealed class Query { public TicketId PrefixedId() => TicketId.CreateSequential(); } ``` Without the runtime bindings, HotChocolate binds by convention and sees a struct with public members: ```graphql type Query { prefixedId: TicketId! } type TicketId { value: UUID! isEmpty: Boolean! } ``` With the generated bindings registered, the id collapses into the scalar it wraps: ```graphql type Query { prefixedId: UUID! } ``` That is the work of the bindings method you registered. The generator wrote three lines into it for `TicketId`: ```csharp title="BindingExtensions.g.cs, shortened" public static IRequestExecutorBuilder AddOrderingGraphQlRuntimeBindings(this IRequestExecutorBuilder builder) { // ... builder.BindRuntimeType(); builder.AddTypeConverter(); builder.AddNodeIdValueSerializer(); return builder; } ``` The first line prints `TicketId` as `UUID`. The second registers a converter between `TicketId` and `Guid`, so a value can cross the boundary in either direction. The third lets a `TicketId` be the key inside a Relay node id; see [Relay node ids](https://dylansnel.github.io/DDDToolkit/docs/graphql.md#relay-node-ids). The two classes they name are generated into `TicketId` as well, and [How the bindings work](https://dylansnel.github.io/DDDToolkit/docs/graphql.md#how-the-bindings-work) shows them. Note that the prefix is not part of the wire format. `TicketId.Create(guid).ToString()` is `"TST_..."`, and the same id serializes as the bare `Guid`. The prefix belongs to `ToString` and `Parse`; see [Prefixes](https://dylansnel.github.io/DDDToolkit/docs/identifiers.md#prefixes). ### Struct ids, class ids and always-valid twins All three bind to the same scalar. | Declaration | Schema type | |---|---| | `[EntityId] readonly partial record struct CatId` | `UUID` | | `[EntityId] partial record PersonId` | `UUID` | | the generated `ValidPersonId` twin | `UUID` | The twin is handled by the same `ChangeTypeProvider` as the type it derives from: one provider carries the conversions for both. See [The always-valid twin](https://dylansnel.github.io/DDDToolkit/docs/value-objects.md#the-always-valid-twin) for what the twin is for. A struct id has no twin, so its provider carries one pair of conversions. [How the bindings work](https://dylansnel.github.io/DDDToolkit/docs/graphql.md#how-the-bindings-work) shows the extra binding a twin gets. Nullability and lists follow from the CLR type, as they do for any other runtime type: ```graphql cat: UUID! nullableCat: UUID cats: [UUID!]! ``` `CatId?` prints as `UUID` and serializes as `null` when absent, so an optional id stays off the heap and still reads correctly on the wire. ### Arguments and input objects The binding is on the runtime type, not on a direction, so the same mapping applies to input. An id used as an argument is declared as the scalar and arrives at the resolver as the id: ```csharp public string DescribeTicketId(TicketId id) => id.ToString(); ``` ```graphql describeTicketId(id: UUID!): String! ``` ```graphql { describeTicketId(id: "44444444-4444-4444-4444-444444444444") } ``` That returns `"TST_44444444-4444-4444-4444-444444444444"`, which only a real `TicketId` can produce; a `string` argument would come back without the prefix. Literals and variables both work, and a variable is declared with the scalar: ```graphql query Echo($id: UUID!) { echoCatId(id: $id) } ``` Input objects work the same way. A plain class with typed id properties is published with the id fields already collapsed: ```csharp public sealed class SeatReservation { public TicketId Ticket { get; set; } public SeatNumber Seat { get; set; } } ``` ```graphql input SeatReservationInput { ticket: UUID! seat: Int! } ``` ## The default scalar mapping When a type carries no `[GraphQLType]`, the generator picks the schema type from the CLR type it wraps: | Wrapped CLR type | HotChocolate type | Schema type | |---|---|---| | `string` | `StringType` | `String` | | `short` | `ShortType` | `Short` | | `int` | `IntType` | `Int` | | `long` | `LongType` | `Long` | | `float` | `FloatType` | `Float` | | `double` | `FloatType` | `Float` | | `decimal` | `DecimalType` | `Decimal` | | `bool` | `BooleanType` | `Boolean` | | `DateTime` | `DateTimeType` | `DateTime` | | `DateTimeOffset` | `DateTimeType` | `DateTime` | | `DateOnly` | `DateType` | `Date` | | `TimeOnly` | `LocalTimeType` | `LocalTime` | | `Guid` | `UuidType` | `UUID` | Wrap anything else and no `BindRuntimeType` line is generated. The converter is still registered, but the type is published by convention, as an object type with a `value` field. Name a schema type yourself if that is not what you want. Two rows in that table are not an exact match of runtime types, and it is worth knowing which. `DateTimeType` has a runtime type of `DateTimeOffset`, and `FloatType` has a runtime type of `double`. So an id or single value object over `DateTime` or over `float` leans on HotChocolate's own conversion between those pairs rather than on anything the toolkit does. Neither pairing is covered by the toolkit's tests. The other eleven rows match exactly, and `Guid`, `int` and `string` are exercised end to end. ### Naming the schema type yourself `[GraphQLType]` overrides the default for one type: ```csharp using DDDToolkit.Abstractions.Attributes; using DDDToolkit.HotChocolate.Attributes; using HotChocolate.Types; [GraphQLType] [SingleValueObject] public partial record EmailAddress { public static EmailAddress Create(string value) => new(value); } ``` The generator reads it and emits the binding it names, for the value object and for its always-valid twin: ```csharp title="BindingExtensions.g.cs, shortened" builder.BindRuntimeType(); builder.BindRuntimeType(); builder.AddTypeConverter(); ``` ```graphql email: EmailAddress! ``` Without the attribute that field would be `String!`. `EmailAddressType` comes from `HotChocolate.Types.Scalars`, which you reference yourself; the toolkit does not bring it in. The attribute applies to identifiers as well as to single value objects, and to struct declarations as well as to records: ```csharp [EntityId("USR")] [GraphQLType] public readonly partial record struct LoginId { public static LoginId Create(string value) => new(value); } ``` `TSchemaType` is constrained to `HotChocolate.Types.ITypeDefinition`. This attribute is the toolkit's own, in `DDDToolkit.HotChocolate.Attributes`, and is not HotChocolate's `GraphQLTypeAttribute`, which annotates individual members and arguments instead of a type. ## Internal members are removed from the schema The toolkit marks its bookkeeping with `[Internal]`, and `IgnoreInternalFieldsInterceptor` keeps every such member out of the schema. It covers object types, input object types and interface types, so a member stays hidden whether it is read or written. Covering interfaces matters too: a field kept on an interface but dropped from an implementing object would make the schema invalid. In practice this hides `IsValid`, `IsValidated`, `EnsureValidated` and the FluentValidation integration's `Errors` on value objects, `DomainEvents` on an aggregate root, and `GetInvariantViolations()` and `GetOwnInvariantViolations()` on every entity. It does not hide anything you did not mark. `Id` and `Version` stay, because they are API: ```graphql type Ticket { holder: EmailAddress! seat: Int! version: Long! id: UUID! } ``` Asking for a hidden field is a validation error, not an empty result: the field is not in the schema at all, so a query naming it comes back with an `errors` array that names the field. ```graphql { issuedTicket { domainEvents { eventId } } } ``` A type that only a hidden member mentioned, such as FluentValidation's `ValidationFailure`, stays out of the schema as well; [Why it removes fields instead of flagging them](https://dylansnel.github.io/DDDToolkit/docs/graphql.md#why-it-removes-fields-instead-of-flagging-them) explains how. See [Hiding members](https://dylansnel.github.io/DDDToolkit/docs/value-objects.md#hiding-members) for what `[Internal]` means elsewhere, and [Optimistic concurrency](https://dylansnel.github.io/DDDToolkit/docs/entities-and-aggregates.md#optimistic-concurrency) for `Version`. ## Domain types publish their data, not their behaviour HotChocolate binds implicitly unless a type says otherwise: every public property and every public method that returns something becomes a field, and a method's parameters become its arguments. For a domain type that publishes its behaviour. A `Money` with `Plus(Money)` and `Times(int)` would come out as ```graphql type Money { plus(other: MoneyInput!): Money! times(quantity: Int!): Money! amount: Decimal! currency: String! } ``` with a `MoneyInput` in the schema only because `plus` needs one. On an aggregate it is worse: a method that changes state and returns a result would become a field, and run inside an ordinary query. Neither GraphQL nor HotChocolate can tell a method with side effects from one without. So `AddDDDToolkitTypes()` registers `DomainBehaviourFieldsInterceptor`, which removes the fields that come from the methods of an entity, an aggregate or a value object, on every object type bound by convention: ```graphql type Money { amount: Decimal! currency: String! } ``` Properties stay, computed ones included, so a value that belongs in the schema is best made a property. A type declared with `BindFieldsExplicitly()` is left alone and publishes exactly what it lists, a method among them if you name one. Fields added by a type extension stay as well. Methods named with `descriptor.Field(...)` on a type that still binds by convention are removed with the rest, because HotChocolate records them the same way as the ones it found itself; declare that type explicitly. Like `[Internal]`, the fields are removed during discovery, so a type that only a method's argument mentioned, such as `MoneyInput`, never enters the schema. ## Failures as GraphQL errors Without help, a resolver that throws `InvalidValueObjectException` or `InvariantViolationException` reaches the client as "Unexpected Execution Error", with no code and no way to tell which field or which rule it was. `AddDDDToolkitErrors()` fixes that: ```csharp services .AddGraphQLServer() .AddDDDToolkitTypes() .AddDDDToolkitErrors(); ``` Each failure becomes an error of its own, with the code in `extensions.code`: ```json { "errors": [{ "message": "Een order mag hooguit € 1.000,00 zijn.", "path": ["placeOrder"], "extensions": { "code": "Order.OverCreditLimit", "entity": "Order", "entityId": "ORD_…", "arguments": { "CreditLimit": 1000 } } }] } ``` | Exception | Errors | Extensions | | --- | --- | --- | | `InvalidValueObjectException` | one per `ValidationError` | `code`, `field`, `arguments` | | `InvariantViolationException` | one per `InvariantViolation`, children included | `code`, `entity`, `entityId`, `arguments` | The rejected value itself is never sent back; it may be a password. Every other error passes through untouched. The message is phrased in the reader's language when the application registered an `IFailureLocalizer`, by calling `AddDDDToolkitLocalization()` from `DDDToolkit.Localization`, and is the domain's own sentence otherwise. The language is the request's UI culture, so put `app.UseRequestLocalization(...)` before `app.MapGraphQL()`. See [Localization](https://dylansnel.github.io/DDDToolkit/docs/localization.md). ## Relay node ids HotChocolate's global object identification works with the toolkit's identifiers as they are. Make a type a node the way HotChocolate documents it, with the identifier itself as the id: ```csharp builder.Services .AddGraphQLServer() .AddGlobalObjectIdentification() .AddDDDToolkitTypes() .AddOrderingContractsGraphQlRuntimeBindings() .AddType(); public sealed class OrderType : ObjectType { protected override void Configure(IObjectTypeDescriptor descriptor) => descriptor .ImplementsNode() .IdField(order => order.Id) // an OrderId .ResolveNode((context, id) => /* id is an OrderId */); } ``` The bindings method is generated once per project that declares identifiers, and in the example shop `OrderId` is declared in Ordering's contracts project, so it is that project's `AddOrderingContractsGraphQlRuntimeBindings()` that registers it. Why the example gives a module's contract a project of its own is explained in [Module contracts](https://dylansnel.github.io/DDDToolkit/docs/module-contracts.md#a-project-of-its-own). `id` prints as `ID!` and carries a node id such as `T3JkZXI6ERER…`, `node(id:)` finds the order again, and an argument declared `[ID] OrderId id` arrives as the `OrderId` inside the node id. Another type that points at an order can publish the reference as a node id of the owner's type, with `descriptor.Field(shipment => shipment.Order).ID("Order")`, without referencing `Order` at all. What makes that possible is one generated class per identifier. HotChocolate writes a node id through an `INodeIdValueSerializer` for the id's runtime type, and it has serializers for `Guid`, `string`, `int`, `long` and `short` but not for a type it has never seen, so an `OrderId` would fail with *No serializer registered*. The generator therefore adds a nested `NodeIdValueSerializer` to every identifier over one of those five, and `Add{Module}GraphQlRuntimeBindings()` registers it: ```csharp title="BindingExtensions.g.cs, shortened" builder.AddNodeIdValueSerializer(); ``` It derives from HotChocolate's `CompositeNodeIdValueSerializer` and writes the wrapped value with HotChocolate's own helpers. The node id is therefore byte for byte the one HotChocolate writes for the bare `Guid`: `Order:` followed by the value. Any HotChocolate server reads it, and so does a Fusion gateway, which routes `node(id:)` by the type name in front and never looks at the value. A single value object gets no serializer, because it is not an identity. [How the bindings work](https://dylansnel.github.io/DDDToolkit/docs/graphql.md#how-the-bindings-work) shows the generated class. Do not use HotChocolate's own `AddNodeIdValueSerializerFrom()` for a toolkit identifier. Its generator reads the members of the type, and an identifier's `Value` is written by the toolkit's generator; source generators do not see each other's output, so it finds no member and emits a serializer that writes nothing and reads every node id back as an empty id. It compiles without a warning. ## The DomainEvent interface `AddDDDToolkitTypes()` registers one type of its own: a GraphQL interface over `IDomainEvent`. ```graphql "Something that happened in the domain." interface DomainEvent { "Unique, stable identifier of this occurrence. Use it for idempotent handling." eventId: UUID! "When the event occurred (UTC)." occurredAt: DateTime! "The runtime type name of the event." eventType: String! } ``` The interface puts no event into the schema; which events a client can see is up to the fields and types you declare. You expose events on purpose, for a reader that wants to know what happened rather than what is: an order's history on a back-office screen, say. Without the interface, each event type is an object type unrelated to the others, so a field that returns several kinds of event needs a union you declare yourself, and a union has no fields in common: the client needs a fragment per event type even to read when each one happened. With it, a resolver can return `IReadOnlyList`, which prints as `[DomainEvent!]!`, and any event type you expose implements the interface automatically, because it implements `IDomainEvent`: ```graphql type TicketIssued implements DomainEvent { ticketId: UUID! eventId: UUID! occurredAt: DateTime! eventType: String! } ``` `eventId` and `occurredAt` come straight from the event. `eventType` is a resolver, and it gives a client a discriminator without a fragment per concrete type: ```graphql { latestEvent { ... on DomainEvent { eventType } } } ``` Exposing a domain event is a field you decided on, answering a query from a reader you chose. It is not how anybody else learns what happened. Another module reads an integration event, and a subscribed client is pushed the contract, never the domain event, for the reasons in [It publishes the contract, never the domain event](https://dylansnel.github.io/DDDToolkit/docs/graphql.md#it-publishes-the-contract-never-the-domain-event). Be aware of what `eventType` currently returns. It resolves to the CLR type name of the event, so `TicketIssued` returns `"TicketIssued"`, not its stored name `ordering.ticket-issued`, and `[DomainEventName]` does not change it. If you rename the class, the value changes with it. Treat `eventType` as a hint for building a client, not as the stable wire name; the stable name is [`DomainEventName.Of()`](https://dylansnel.github.io/DDDToolkit/docs/domain-events.md#stable-names). ## One schema over a modular monolith HotChocolate Fusion puts a gateway in front of several GraphQL services and composes their schemas, each one a source schema, into one. `DDDToolkit.HotChocolate.Fusion.InMemory` makes the modules of a monolith what Fusion makes services: each module serves a GraphQL source schema of its own, and a Fusion gateway inside the application composes them into one schema and answers every query by calling them directly, in memory, with no HTTP. ```bash dotnet add package Temp.DDDToolkit.HotChocolate.Fusion.InMemory ``` **It needs HotChocolate Fusion 16.6.6 or later**, where the other HotChocolate integration asks for 16.0.0: the in-memory connector it builds on is newer than that. The package declares it, so NuGet refuses an older HotChocolate rather than a gateway that fails at run time. How the example shop's `Product` comes together. No module references another's classes; they agree on a type name and a key: ```mermaid flowchart LR subgraph catalog ["Catalog's source schema"] CatalogProduct["Product: sku, name, price"] end subgraph inventory ["Inventory's source schema"] InventoryProduct["Product: sku, stock"] end subgraph ordering ["Ordering's source schema"] Line["OrderLine: product, a Product by its sku"] end catalog --> Gateway["Fusion gateway, in the application"] inventory --> Gateway ordering --> Gateway Gateway --> Client["one Product: sku, name, price, stock"] ```
Show the code: Inventory's Product and Ordering's reference to one Inventory declares its own `Product`, keyed on the SKU, with the one field it knows, and an internal lookup the gateway fetches it by: ```csharp public sealed record InventoryProduct(string Sku); public sealed class InventoryProductType : ObjectType { protected override void Configure(IObjectTypeDescriptor descriptor) { descriptor.Name("Product"); descriptor.BindFieldsExplicitly(); descriptor.Directive(new EntityKey("sku")); descriptor.Field(product => product.Sku); descriptor .Field("stock") .Type() .Resolve(async context => await context.DataLoader() .LoadAsync(context.Parent().Sku, context.RequestAborted)); } } [ExtendObjectType(OperationTypeNames.Query)] public sealed class InventoryProductLookup { [Lookup] [Internal] public InventoryProduct GetProductBySku(string sku) => new(sku); } ``` *[`Inventory/Api/GraphQL/ProductStock.cs`](https://github.com/DylanSnel/DDDToolkit/blob/main/Examples/Modules/Inventory/DDDToolkit.Examples.Inventory/Api/GraphQL/ProductStock.cs)* Ordering knows only the SKU on a line, and says that it is a `Product`: ```csharp public sealed record ProductStub(string Sku); public sealed class ProductStubType : ObjectType { protected override void Configure(IObjectTypeDescriptor descriptor) { descriptor.Name("Product"); descriptor.BindFieldsExplicitly(); descriptor.Directive(new EntityKey("sku")); descriptor.Field(product => product.Sku); } } [ExtendObjectType] public sealed class OrderLineProductStub { public ProductStub? GetProduct([Parent] OrderLine line) => new(line.Sku); } ``` *[`Ordering/Api/GraphQL/ProductStub.cs`](https://github.com/DylanSnel/DDDToolkit/blob/main/Examples/Modules/Ordering/DDDToolkit.Examples.Ordering/Api/GraphQL/ProductStub.cs)*
Two modules can each declare their own `Product`, keyed on the same field, and a client sees one: ```csharp // Catalog's source schema: the product's name and price, and the lookup it is fetched by builder.Services.AddGraphQLServer("catalog").AddSourceSchemaDefaults().AddQueryType()...; // Inventory's: its own Product, keyed on the SKU, with the stock, and an internal lookup builder.Services.AddGraphQLServer("inventory").AddSourceSchemaDefaults().AddQueryType()...; builder.Services.AddInMemoryFusionGateway(); // composes every source schema registered var app = builder.Build(); app.MapInMemoryFusionGateway(); // /graphql ``` ```graphql { productBySku(sku: "COFFEE-1KG") { name price { amount } stock { available } } } # └─ Catalog ─────────┘ └─ Inventory ─────┘ ``` A module's GraphQL is then the same code in the monolith and as a service behind a Fusion gateway, and the modules still know nothing of each other's classes: they agree on a type's name and its key. `Examples/ModularMonolith.*` register every module this way; see the examples' README. Three things it takes care of, all found the hard way and shown in `Tests/Spikes/DDDToolkit.Spikes.FusionInProcess`: - **The gateway has a service container of its own**, inside the application. HotChocolate and Fusion each register themselves as the one `IRequestExecutorProvider` of a container: the endpoint asks it for the gateway, and HotChocolate's in-memory connector asks it for the modules, so with `AddGraphQLGatewayServer().AddInMemorySchema(...)` and more than one source schema one of the two loses ("The requested schema '_Default' does not exist"). The package hands the gateway the modules explicitly, through the same public classes `AddInMemorySchema` uses. - **A composition that fails does not hang.** The in-memory connector reports a composition error only to observers subscribed at that moment, and the gateway then waits for a schema forever. The application's start waits for the composed schema instead, and fails after `InMemoryFusionGatewayOptions.CompositionTimeout` (thirty seconds), with the composer's error when it was caught. - **Relay spans the modules**: `node(id:)` is answered by whichever module owns the type in the id. And one rule that is Fusion's, not the package's: every source schema's root query type must be called `Query`. `AddQueryType()` names it `CatalogQuery`, and composition refuses it. ## Value objects in a Fusion source schema A Fusion gateway composes one schema out of the source schemas of several services, and a field belongs to one of them unless it is marked `@shareable`: the gateway has to know who answers it. An entity has an owner, and other services add fields to it by its key. A value object has neither owner nor identity. `Money` in Catalog's prices and `Money` in Payments' amounts are the same type, and any service that holds one gives the same answer for it, which is exactly what `@shareable` says. Without it, composition refuses the second service that returns a `Money`: ``` The field 'Money.amount' in schema 'payments' must be shareable. ``` So in a source schema `AddDDDToolkitTypes()` marks every value object type `@shareable`: ```csharp builder.Services .AddGraphQLServer() .AddSourceSchemaDefaults() // HotChocolate: this schema is one a gateway composes .AddDDDToolkitTypes(); // value objects become @shareable ``` ```graphql type Money @shareable { amount: Decimal! currency: String! } ``` A schema without `AddSourceSchemaDefaults()` gets no directive. Entities stay unshared: a service that adds to another's entity declares a stub of it, keyed on its id, the way `Examples/Microservices.*` do for `Order`. ## Pushing integration events to subscribers `GraphQlSubscriptionSink` is an [integration event](https://dylansnel.github.io/DDDToolkit/docs/integration-events.md) sink that publishes to HotChocolate's `ITopicEventSender`, so a message leaving the outbox reaches the clients holding a socket right now. **It is a different thing from the other sinks, and worth being blunt about.** The in-process module sink is how one module tells another that something happened. pgmq is how a module tells another deployable the same thing. Both are integration: durable, retried, and the receiver gets the message whether or not it was running at the time. A subscription is none of that: - **Not durable.** Nothing is stored. A payload with no subscriber is dropped. - **Only the connected.** A client that reconnects has missed what happened while it was away, and has to re-read the state it cares about. - **No acknowledgement.** The sink returns once the topic accepted the payload. Whether a socket survived long enough to deliver it is not knowable from there. So use it to keep a screen in step with the server. Never as the path by which some other part of the system learns that an order was placed. If a browser missing an update would be a bug in your data rather than a stale view, this is the wrong mechanism. ### Registering it ```csharp builder.Services .AddGraphQLServer() .AddDDDToolkitTypes() .AddOrderingGraphQlRuntimeBindings() .AddInMemorySubscriptions() .AddQueryType() .AddSubscriptionType(); builder.Services.AddIntegrationEventSubscriptions(map => map.Publish("orderPlaced")); ``` ```csharp options.UseOutbox(outbox => { outbox.PublishAs(e => new OrderPlacedV2(e.OrderId.Value, e.Total.Amount)); outbox.SendTo(); }); ``` The subscription field reads the same topic: ```csharp public sealed class Subscription { [Subscribe(With = nameof(OnOrderPlacedAsync))] public OrderPlacedV2 OrderPlaced([EventMessage] OrderPlacedV2 order) => order; public ValueTask> OnOrderPlacedAsync( [Service] ITopicEventReceiver receiver, CancellationToken cancellationToken) => receiver.SubscribeAsync("orderPlaced", cancellationToken); } ``` ```graphql subscription { orderPlaced { orderId total } } ``` Nothing is pushed until the map names it. That is the point: a subscription payload is part of your schema, and a schema is a deliberate list rather than whatever happened to be published. ### It publishes the contract, never the domain event This is not tidiness. A subscription payload is a schema type. It is declared on the subscription field, it is resolved through the schema, and the schema's own authorisation decides what the client may see, field by field. `[Internal]` members are already gone, and an `[Authorize]` on a field still applies. Broadcast the domain event instead and you are pushing your internal record, with your identifiers and whatever you last added to it, to whoever is holding a socket. The contract is the list of things you decided to say out loud, and it is the same contract the other modules read, so there is one published shape rather than two. No upcasting happens on this path, and that is deliberate. An upcaster catches a reader up with a payload written before it was deployed. A subscription payload was produced seconds ago by this process, against this schema. If the map does not name the message's name **and version**, it is simply not pushed. ### Scoping a topic A constant topic is a firehose that every subscriber sees. Build the topic from the message to scope it, usually to one aggregate: ```csharp map.Publish(message => $"order:{message.AggregateId}"); ``` Returning `null` from the factory drops that one occurrence. A topic string is not authorisation. It decides who is woken; the subscription field decides who is allowed to subscribe and what they then see. Put the `[Authorize]` on the field. ### Transports `AddInMemorySubscriptions()` is enough for a single process. At 16.6.6 HotChocolate also ships transports for Postgres, Redis, RabbitMQ and NATS, for when more than one server is holding sockets and the server that published is not the server the client is connected to. The toolkit's sink goes through `ITopicEventSender` and does not care which of them is registered. The Postgres one is worth knowing about if you are already on Postgres. It rides `LISTEN` and `NOTIFY`, so several servers share subscriptions with no extra infrastructure at all: no Redis, no broker, nothing new to run or pay for. Combined with the [pgmq sink](https://dylansnel.github.io/DDDToolkit/docs/transports.md#when-a-module-becomes-its-own-deployable-pgmq), a Postgres deployment can carry both the durable path and the live path without another service. `Tests/DDDToolkit.HotChocolate.Tests/SubscriptionSinkTests.cs` runs the whole thing against the real in-memory transport, including a client subscribing through the schema and receiving what the outbox published. ## What this package does not do It maps identifiers and single value objects onto scalars, and identifiers into Relay node ids. It does not make any type a node: `ImplementsNode()` is yours to call. A multi-property `[ValueObject]` stays the object type HotChocolate would infer, less its methods, and it generates no queries, mutations or resolvers. The schema is still yours to write. That includes subscriptions. The sink publishes a contract to a topic; the subscription field, its arguments and its authorisation are yours, and so is the choice of transport. ## How the bindings work Two generated pieces turn an id into a scalar, and a third makes it a node id. For every identifier and single value object, the generator emits a nested `ChangeTypeProvider` class implementing `HotChocolate.Utilities.IChangeTypeProvider`. It converts the object to its wrapped value and back, and declines every other pair: ```csharp title="TicketId.HotChocolate.g.cs, shortened" readonly partial record struct TicketId { public sealed class ChangeTypeProvider : IChangeTypeProvider { public bool TryCreateConverter(Type source, Type target, HotChocolate.Utilities.ChangeTypeProvider root, [NotNullWhen(true)] out ChangeType? converter) { if (source == typeof(TicketId) && target == typeof(Guid)) { converter = value => ((TicketId)value!).Value; return true; } if (source == typeof(Guid) && target == typeof(TicketId)) { converter = value => new TicketId((Guid)value!); return true; } converter = null; return false; } } // ... } ``` Asked directly, it answers like this: ```csharp var provider = new TicketId.ChangeTypeProvider(); // root is the fallback provider HotChocolate passes in; these converters never delegate to it. provider.TryCreateConverter(typeof(TicketId), typeof(Guid), root, out var toValue); // true provider.TryCreateConverter(typeof(Guid), typeof(TicketId), root, out var fromValue); // true provider.TryCreateConverter(typeof(TicketId), typeof(string), root, out var other); // false ``` The registration method then binds the runtime type to a schema type and registers that provider. This is the method for a project that declares, among others, `EmailAddress`, `PersonId` and `TicketId`: ```csharp title="BindingExtensions.g.cs, shortened" namespace Ordering.Domain.GraphQl; public static class HotChocolateExtensions { public static IRequestExecutorBuilder AddOrderingGraphQlRuntimeBindings(this IRequestExecutorBuilder builder) { // ... builder.BindRuntimeType(); builder.BindRuntimeType(); builder.AddTypeConverter(); // ... builder.BindRuntimeType(); builder.BindRuntimeType(); builder.AddTypeConverter(); builder.AddNodeIdValueSerializer(); // ... builder.BindRuntimeType(); builder.AddTypeConverter(); builder.AddNodeIdValueSerializer(); return builder; } } ``` The binding decides how the type is printed. The converter decides how a value moves across the boundary in either direction. A type with an always-valid twin, such as the class id `PersonId`, gets a second binding for the twin, and its provider carries a second pair of conversions, between `ValidPersonId` and `Guid`. `EmailAddress` is bound to the schema type its `[GraphQLType]` names, and gets no node id serializer, because it is a single value object and not an identity. The third piece is the `NodeIdValueSerializer`, generated into every identifier over `Guid`, `string`, `int`, `long` or `short`. It writes the wrapped value into a Relay node id and reads it back out; [Relay node ids](https://dylansnel.github.io/DDDToolkit/docs/graphql.md#relay-node-ids) says why the toolkit writes it rather than HotChocolate: ```csharp title="TicketId.HotChocolate.g.cs, shortened" readonly partial record struct TicketId { // ... public sealed class NodeIdValueSerializer : CompositeNodeIdValueSerializer { protected override NodeIdFormatterResult Format(Span buffer, TicketId value, out int written) { return TryFormatIdPart(buffer, value.Value, out written) ? NodeIdFormatterResult.Success : NodeIdFormatterResult.BufferTooSmall; } protected override bool TryParse(ReadOnlySpan buffer, out TicketId value) { if (TryParseIdPart(buffer, out Guid raw, out _)) { value = new TicketId(raw); return true; } value = default; return false; } } } ``` `TryFormatIdPart` and `TryParseIdPart` are HotChocolate's own helpers, which is why the node id has exactly the format HotChocolate gives a bare `Guid`. ## Why it removes fields instead of flagging them `IgnoreInternalFieldsInterceptor` takes `[Internal]` members out of the schema by removing their fields. HotChocolate offers a per-field ignore flag at type completion, and using only that is not enough. A field is dropped from the printed schema when it is flagged, but the types it refers to have already been registered by then. Discovery walks the fields to work out what each one depends on, so flagging the FluentValidation integration's `Errors` at completion still leaves `ValidationFailure` and its `Severity` enum registered as schema types that no field can reach. So the interceptor removes the fields in `OnBeforeRegisterDependencies`, during type discovery and before dependencies are computed. It still flags in `OnBeforeCompleteType`, for anything internal that appeared after discovery, such as a merged type extension. The visible effect is that the schema contains no type that exists only because a hidden field mentioned it. `ValidationFailure` and `Severity` are absent, and so is the `ValidPersonName` twin that a value object's `[Internal]` `ToValid()` would otherwise have pulled in. # FluentValidation A value object carries its own rules; [Value objects](https://dylansnel.github.io/DDDToolkit/docs/value-objects.md#validation) shows how to write them by hand. If your team already writes rules with [FluentValidation](https://docs.fluentvalidation.net/), there is no reason to stop at the value object. `DDDToolkit.FluentValidation` lets you write a value object's rules as a FluentValidation validator, and lets a value object take part in the validator you write for a command or a request. There is nothing to register. A value object's validator is created where it is used, so no container is involved, and the rest is extension methods. ```bash dotnet add package Temp.DDDToolkit.FluentValidation ``` ## A value object's rules Reference the package and the generator gives every value object a nested `Validator`. You write the other half, with the rules: ```csharp [SingleValueObject] public partial record EmailAddress { public static EmailAddress Create(string value) => new(value); partial class Validator { public Validator() => RuleFor(x => x.Value).EmailAddress(); } } ``` The generator writes the base class of that validator, the `Validate()` overrides that run it, and an `Errors` collection with what it found: ```csharp title="EmailAddress.FluentValidation.g.cs, shortened" partial record EmailAddress { [Internal] [NotMapped] public ReadOnlyCollection Errors => _errors.AsReadOnly(); private List _errors = new(); protected override bool Validate() { var validator = new Validator(); var result = validator.Validate(this); _errors = result.Errors; return result.IsValid; } protected override void Validate(ValidationErrorBuilder errors) { foreach (var failure in _errors) { // ... copies each failure, with its placeholder values as arguments, into a ValidationError } } partial class Validator : FluentValidation.AbstractValidator { } } ``` The two halves of `Validator` are the point: the generator states the base class, you state the rules. A value object with several properties is the same, with a rule per property: ```csharp [ValueObject] public partial record Address { public Address(string street, string city, string postalCode) => (Street, City, PostalCode) = (street, city, postalCode); public string Street { get; protected init; } public string City { get; protected init; } public string PostalCode { get; protected init; } partial class Validator { public Validator() { RuleFor(x => x.Street).NotEmpty(); RuleFor(x => x.City).NotEmpty(); RuleFor(x => x.PostalCode).Matches(@"^\d{4}\s?[A-Za-z]{2}$"); } } } ``` Which types get a validator: | Declaration | Validator | |---|---| | `[ValueObject]` | Yes | | `[SingleValueObject]` | Yes | | `[EntityId] partial record`, the record form of an identifier | Yes | | `[EntityId] partial record struct` | No: a struct identifier is well formed by construction | A value object that writes its own `Validate()` or `Validate(ValidationErrorBuilder)`, in any part, is left alone: it gets no `Validator`, no `Errors` and no generated overrides, so one project can mix hand-validated value objects with ones validated by rules. Only those two signatures count; a `Validate` with other parameters is an ordinary method, and the type still gets its validator. ## What the rules produce Everything the value object already offers now runs your validator: `IsValid`, `ToValid()`, `TryToValid()` and the [always-valid twin](https://dylansnel.github.io/DDDToolkit/docs/value-objects.md#the-always-valid-twin). The failures come out in two shapes, one for each kind of caller: ```mermaid flowchart LR Rules["your rules, in the partial Validator"] --> Validate["the generated Validate()"] Validate --> Errors["Errors: FluentValidation's ValidationFailure"] Validate --> Toolkit["ValidationErrors: the toolkit's ValidationError, with a code and arguments"] Validate --> Valid["IsValid"] Toolkit --> Try["TryToValid(), and the twin"] Toolkit --> Localization["DDDToolkit.Localization"] Valid --> Must["MustBeValid(), in a request validator"] ``` `Errors` is FluentValidation's own `ValidationFailure`, handy when you already work in that library. `ValidationErrors` and `TryToValid()` hand back the toolkit's `ValidationError`, which carries the same property, code and attempted value, and FluentValidation's placeholder values as `Arguments`. A caller reads those without referencing FluentValidation at all: ```csharp var email = EmailAddress.Create("nope"); email.IsValid; // false email.Errors[0].PropertyName; // "Value" email.TryToValid(out var valid, out var errors); errors[0].Code; // "EmailValidator" errors[0].Message; // "'Value' is not a valid email address." errors[0].AttemptedValue; // "nope" ```
Show the code: rules, a request validator and an endpoint The value objects state their rules as above. The request validator adds what is about the request, and folds the value objects in: ```csharp public sealed record PlaceOrder(EmailAddress? Email, Address? ShipTo, int Quantity); public sealed class PlaceOrderValidator : AbstractValidator { public PlaceOrderValidator() { RuleFor(x => x.Email).NotNull().MustBeValid(); RuleFor(x => x.ShipTo).NotNull().MustBeValid(); RuleFor(x => x.Quantity).GreaterThan(0); } } ``` The endpoint turns the result into the toolkit's failures, and those into a 400: ```csharp app.MapPost("/orders", (PlaceOrder body) => { var errors = new PlaceOrderValidator().Validate(body).ToValidationErrors(); if (errors.Count > 0) { return Results.ValidationProblem(errors.ToErrorDictionary()); } // ... }); ```
## A value object in a request validator A value object validates itself, which is not the same as taking part in the validator you write for a command or a request. `MustBeValid()` folds it into one: ```csharp public sealed class PlaceOrderValidator : AbstractValidator { public PlaceOrderValidator() { RuleFor(x => x.Email).NotNull().MustBeValid(); RuleFor(x => x.ShipTo).NotNull().MustBeValid(); RuleFor(x => x.Quantity).GreaterThan(0); } } ``` The failure is reported against the containing property, so the caller gets one flat result rather than an exception per field. For a request with an invalid email address, an empty city and no quantity, it reads: | Property | Code | Message | |---|---|---| | `Email` | `ValueObjectValidator` | 'Email' is not a valid EmailAddress. | | `ShipTo` | `ValueObjectValidator` | 'Ship To' is not a valid Address. | | `Quantity` | `GreaterThanValidator` | 'Quantity' must be greater than '0'. | `ValueObjectValidator` is `ValueObjectRules.ErrorCode`, so a caller can branch on it. The value object's type name travels as the `ValueObject` argument rather than as text in the message, so a translation can put it where its grammar wants it; see [Localization](https://dylansnel.github.io/DDDToolkit/docs/localization.md). A `null` property passes, exactly as with FluentValidation's own rules, so chain `NotNull()` when the value is required. ## One list for the whole request `ToValidationErrors()` turns a FluentValidation result, or any list of `ValidationFailure`, into the toolkit's `ValidationError`. That puts the request validator's failures in the same list as the ones `TryToValid()` hands back, which is what an endpoint wants when it answers with every failure at once: ```csharp public sealed record CheckoutRequest(string Street, string City, string PostalCode, int Quantity); public sealed class CheckoutRequestValidator : AbstractValidator { public CheckoutRequestValidator() => RuleFor(x => x.Quantity).GreaterThan(0); } ``` ```csharp var errors = new List(new CheckoutRequestValidator().Validate(body).ToValidationErrors()); if (!new Address(body.Street, body.City, body.PostalCode).TryToValid(out var shipTo, out var addressErrors)) { errors.AddRange(addressErrors.Prefixed("shipTo")); } if (errors.Count > 0) { return Results.ValidationProblem(errors.ToErrorDictionary()); } ``` `Prefixed()` and `ToErrorDictionary()` are the toolkit's, and work the same without FluentValidation; see [Returning a refusal from an endpoint](https://dylansnel.github.io/DDDToolkit/docs/value-objects.md#returning-a-refusal-from-an-endpoint). ## Messages in the reader's language FluentValidation phrases `Message` in its own languages. `DDDToolkit.Localization` goes further, because every failure arrives with its code and its placeholder values (`MaxLength`, `ComparisonValue`, `ValueObject`) as `Arguments`: a translation for your own codes and for FluentValidation's is looked up by code, and filled in from the arguments. Nothing on the value object changes. See [Localization](https://dylansnel.github.io/DDDToolkit/docs/localization.md). ## What it does not do - **No dependency injection.** A value object's `Validator` is created with `new` wherever the value is validated, so it cannot take services in its constructor. A rule that needs a service, such as "this email address is not taken", is not a rule about the value; put it in the request validator. - **No asynchronous rules on a value object.** It validates synchronously, whenever `IsValid` is first read, so `MustAsync` and other async rules have nowhere to run. A request validator can have async rules of its own and run with `ValidateAsync`; `MustBeValid()` in it is a synchronous rule. - **No rules across value objects.** A value object's rules see that value object. Rules about the request as a whole belong in the request validator, next to `MustBeValid()`. # Localization A failure the toolkit reports carries three things: a stable **code**, a **message** in the domain's own words, and the **arguments** that message was built from. `DDDToolkit.Localization` uses the first and the third to phrase the failure again in the reader's language, at the moment it becomes a response. This covers every failure the toolkit hands you: `ValidationError` from a value object or a FluentValidation validator, and `InvariantViolation` from `GetInvariantViolations()` or from `InvariantViolationException.InvariantViolations` on the throwing path. ## The rule: the domain stays language-free The domain never looks at a culture. An invariant runs on a save in a background job as readily as in a request, and there is no reader there to ask. So the domain reports *what* is wrong, and the edge decides *how to say it*: ```text domain edge ────── ──── Code "Till.OverLimit" ──► looked up in your resx for the current UI culture Message "A till holds at most 100…" kept as the fallback when nobody translated the code Arguments { Limit: 100, Cash: 150 } fill the translated template ``` A failure whose code nobody translated keeps the domain's own message, so adding localization never makes a response worse than it was. ## Setting it up ```bash dotnet add package Temp.DDDToolkit.Localization ``` `IFailureLocalizer` itself lives in the core `DDDToolkit` package, so an integration can ask for one without depending on how translations are stored. `DDDToolkit.Localization` provides the implementation that reads them through `IStringLocalizer`. ```csharp builder.Services.AddLocalization(); builder.Services.AddDDDToolkitLocalization(options => options.AddResource()); var app = builder.Build(); app.UseRequestLocalization("en", "nl"); ``` `SharedFailures` is an empty marker class with `SharedFailures.resx`, `SharedFailures.nl.resx` and so on beside it, found exactly as `IStringLocalizer` finds them. Sources are asked in the order they were added. `AddLocalizer(IStringLocalizer)` adds one you built yourself, such as one that reads translations from a database. The language is `CultureInfo.CurrentUICulture`, which the request localization middleware sets per request. Numbers and dates inside a message are formatted with `CultureInfo.CurrentCulture`. ## Writing translations The key is the failure's code. The value is a template with named placeholders: ```xml In deze kassa mag hooguit {Limit:C} zitten, en er zit {Cash:C} in. {PropertyName} is hooguit {MaxLength} tekens lang. ``` - `{Name}` is replaced by the argument of that name, matched without regard to case. - `{Name:format}` applies a .NET format string, so `{Limit:C}` is a currency in the current culture. - `{{` and `}}` are literal braces. - A placeholder with no value is left as written, so a typo shows on screen rather than as an exception on the failure path. Besides its arguments, a template can name the failure's own members: | Failure | Placeholders | | --- | --- | | `ValidationError` | `{PropertyName}`, `{AttemptedValue}`, `{Code}` | | `InvariantViolation` | `{EntityType}`, `{EntityId}`, `{Code}` | An argument of the same name wins. An invariant violation is looked up as `{EntityType}.{Code}` first and then as the bare code. A rule shared by several entities can then have one translation, and still get a different one where the sentence has to differ: `Drawer.NotNegative` beats `NotNegative`. ## Using it `IFailureLocalizer` phrases one failure. The extension methods phrase a list and keep everything else, so the result can still be branched on: ```csharp app.MapPost("/subscribers", (SubscribeRequest body, IFailureLocalizer localizer) => { if (!EmailAddress.Create(body.Email).TryToValid(out var email, out var errors)) { return Results.ValidationProblem(errors.Prefixed("email").ToErrorDictionary(localizer)); } return Results.Ok(subscribers.Add(email)); }); ``` ```csharp var violations = order.GetInvariantViolations(); if (violations.Count > 0) { return Results.UnprocessableEntity(violations.Localized(localizer).Select(v => new { v.Code, v.Message })); } ``` On the throwing path, `InvariantViolationException.InvariantViolations` holds the same violations, codes and arguments included, one for one with the phrased `Violations`: ```csharp catch (InvariantViolationException exception) { return Results.UnprocessableEntity(exception.InvariantViolations.Localized(localizer) .Select(v => new { v.Code, v.Message })); } ``` ## Giving a failure its arguments A template's placeholders are filled from the failure's `Arguments`, so a failure whose message names a value has to carry that value as well. ### Value objects A rule you write by hand adds its values next to its message: ```csharp protected override void Validate(ValidationErrorBuilder errors) { if (Value.Length > 40) { errors.Add("A street is at most 40 characters.", nameof(Value), "Street.TooLong", Value, new Dictionary { ["MaxLength"] = 40 }); } } ``` `new ValidationError(...).With("MaxLength", 40)` does the same for a single failure. With [FluentValidation](https://dylansnel.github.io/DDDToolkit/docs/fluent-validation.md) you write nothing: every placeholder value FluentValidation knows (`MaxLength`, `TotalLength`, `ComparisonValue`, `PropertyName`, and anything you add with `context.MessageFormatter.AppendArgument`) arrives in `Arguments`, and the error code (`MaximumLengthValidator`, `NotEmptyValidator`, ...) is the key. The same goes for `result.ToValidationErrors()` on a validator you wrote yourself. FluentValidation also has its own translations, and they are used for `Message`. But a value object validates once and caches the verdict, so that message is in whichever language was current *the first time* anything asked. The localizer phrases the failure when it is read, which is the moment that counts. ### Invariants `IInvariant.Check` returns an `InvariantFailure`. A string converts to one, so a rule with nothing to add returns its message. A rule whose message names values adds them: ```csharp public partial class Order { public sealed class MustStayWithinTheCreditLimit : IInvariant { public const string ViolationCode = "Order.OverCreditLimit"; public string Code => ViolationCode; public InvariantFailure? Check(Order order) => order.Total <= order.CreditLimit ? null : new InvariantFailure($"An order may total at most {order.CreditLimit}.") .With("CreditLimit", order.CreditLimit) .With("Total", order.Total); } } ``` The rule is nested inside the entity it is about, which is where the generator looks for rules to run. The happy path returns `null` and allocates nothing. The `CheckInvariants()` seam reports strings, so its violations carry `InvariantViolation.SeamCode` and have no code worth translating. A rule that needs translating needs a code, which is one more reason to give it [a type of its own](https://dylansnel.github.io/DDDToolkit/docs/invariants.md#a-named-invariant). ## GraphQL `DDDToolkit.HotChocolate` has an error filter that does all of the above for you: every failure the toolkit throws becomes a GraphQL error of its own, with its code in the extensions and its message in the reader's language. ```csharp services.AddGraphQLServer() .AddDDDToolkitTypes() .AddDDDToolkitErrors(); ``` See [GraphQL](https://dylansnel.github.io/DDDToolkit/docs/graphql.md#failures-as-graphql-errors). ## One module, one resx Calls to `AddDDDToolkitLocalization()` add up, so each module registers its own translations from its own composition method and every one of them is asked, in the order the calls were made: ```csharp public static IServiceCollection AddOrdering(this IServiceCollection services) => services.AddDDDToolkitLocalization(o => o.AddResource()); public static IServiceCollection AddShipping(this IServiceCollection services) => services.AddDDDToolkitLocalization(o => o.AddResource()); ``` ## The toolkit's own messages The toolkit writes two messages itself, and ships them in English and Dutch: | Code | Where it comes from | Placeholders | | --- | --- | --- | | `Unspecified` | a `Validate()` that returned `false` without saying why | `{ValueObject}` | | `ValueObjectValidator` | `MustBeValid()` in `DDDToolkit.FluentValidation` | `{PropertyName}`, `{ValueObject}` | They are asked last, so a key of the same name in your own resx overrides them, and adding another language is adding those two keys to it. Override them in every language you support, not only in your neutral resx. `IStringLocalizer` falls back from `SharedFailures.nl.resx` to `SharedFailures.resx` before the toolkit is asked at all, so an override in the neutral file alone gives Dutch readers your English text instead of the toolkit's Dutch. The check below reports exactly that. ## Checking that everything is translated `FailureTranslations` checks that every failure you expect is translated in every language you support, so a missing translation fails a test instead of reaching a user: ```csharp [Fact] public void Every_failure_is_translated_in_every_language() => FailureTranslations .Check(localizer, "en", "nl", "de") // the first is the language of your neutral resx .Invariants(typeof(Order).Assembly) // every IInvariant, found and asked for its code .Codes("Street.TooLong", "City.Required") // codes your value objects report .ToolkitCodes() // Unspecified and ValueObjectValidator .Verify(); ``` `localizer` is the one the application registers, taken from a service provider built the way the application builds it. The check follows each key through each source the way the localizer does at run time, one language level at a time (`nl-NL`, then `nl`, then the neutral resx), and `Verify()` throws with a report: ```text 3 failure translations are missing or hidden: de Order.OverCreditLimit fallsback: readers get SharedFailures.resx's neutral (en) text. nl Nobody.Knows missing: no source knows it, so readers get the domain's own message. nl ValueObjectValidator hidden: SharedFailures.resx has it only in its neutral (en) resx, which wins over the toolkit's nl text. Add it to the nl resx too. ``` | Problem | Meaning | | --- | --- | | `Missing` | No source knows the key, so readers get the domain's own message. | | `FallsBack` | Nothing translates it into this language, so readers get your neutral text or the toolkit's English. | | `Hidden` | The toolkit translates it, but your neutral resx wins first. | Invariants are found for you: every `IInvariant` in the assemblies you name is created and asked for its code, and either of its two keys counts. Codes your value objects report and FluentValidation's codes are strings in your code, so name the ones you expect with `Codes(...)`. `Findings()` returns the same report as a list, for a test that wants to assert something narrower, and nothing here needs a test framework, so the same check works at startup in development: ```csharp if (app.Environment.IsDevelopment()) { FailureTranslations.Check(app.Services.GetRequiredService(), "en", "nl") .Invariants(typeof(Order).Assembly) .Verify(); } ``` # Diagnostics A source generator that produces nothing when it is misused is the hardest kind of bug to find: the type looks annotated and behaves like a plain class. Every misuse below reports a diagnostic instead. | Id | Severity | Meaning | |---|---|---| | [DDD00001](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00001) | Error | Value objects must be records | | [DDD00002](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00002) | Error | Entities must be classes | | [DDD00003](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00003) | Error | Entity ids must be records | | [DDD00004](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00004) | Warning | Entity id structs should be readonly | | [DDD00005](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00005) | Error | DDDToolkit types must be partial | | [DDD00006](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00006) | Error | DDDToolkit types cannot be generic | | [DDD00007](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00007) | Error | The generated identifier name is already taken | | [DDD00008](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00008) | Error | The identifier type argument is not supported | | [DDD00009](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00009) | Error | A type is either an entity or an aggregate root | | [DDD00010](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00010) | Error | Value object properties must use protected setters | | [DDD00011](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00011) | Error | Value object properties must use init setters | | [DDD00013](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00013) | Error | Value objects cannot be sealed | | [DDD00020](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00020) | Error | Generated collection properties must be get-only | | [DDD00021](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00021) | Warning | Reference another aggregate by its id | | [DDD00022](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00022) | Warning | Use only what another module publishes | | [DDD00023](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00023) | Warning | Do not hold another module's entity | | [DDD00024](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00024) | Warning | An invariant must be nested inside the entity it is about | | [DDD00025](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00025) | Warning | An invariant is nested inside a type it is not about | | [DDD00026](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00026) | Warning | Two invariants of one entity share a code | | [DDD00027](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00027) | Error | An invariant needs an accessible parameterless constructor | | [DDD00028](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00028) | Error | A key part belongs on an entity or aggregate root | | [DDD00029](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00029) | Warning | A key part should not have a public setter | | [DDD00030](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00030) | Error | Declare all key parts of a type in one file | | [DDD00031](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00031) | Error | A [SupabaseMigrations] factory must be one the build can create | | [DDD00032](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00032) | Warning | Do not ask HotChocolate's generator for a toolkit identifier's node id serializer | | [DDD00033](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00033) | Warning | The generated integration event registration must be able to construct the class | | [DDD00034](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00034) | Warning | An event's class name and its Version disagree | | [DDD00035](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00035) | Error | An event's class name ends in something that is not a version | | [DDD00036](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00036) | Error | Two events of one module share a name and version | | [DDD00037](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00037) | Error | Two event names give one constant name | | [DDD00038](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00038) | Error | A row access rule is a static partial class with one Allows method | | [DDD00039](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00039) | Error | A row access rule can only say what the database can check | | [DDD00040](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00040) | Error | A row access rule guards an aggregate root | Most of these say the generator could not do what you asked. The rest are a different kind: they are rules about the model rather than about the declaration, and each of them names code that compiles, reads well and does not do what it looks like it does. [DDD00021](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00021) is about the boundary between two aggregates; [DDD00022](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00022) and [DDD00023](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00023) are about the boundary between two [modules](https://dylansnel.github.io/DDDToolkit/docs/modules.md) and say nothing at all until a project declares itself one; [DDD00024](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00024) to [DDD00027](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00027) are about [invariants](https://dylansnel.github.io/DDDToolkit/docs/invariants.md), where the failure worth catching is a rule that is written, tested, and never run; [DDD00028](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00028) to [DDD00030](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00030) are about [composite keys](https://dylansnel.github.io/DDDToolkit/docs/composite-keys.md), and the section after them lists the one key-part mistake that can only be caught when the Entity Framework model is built. [DDD00031](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00031) is about the [Supabase export](https://dylansnel.github.io/DDDToolkit/docs/supabase.md), where the failure worth catching is a module whose migrations never reach Supabase. [DDD00032](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00032) is about [Relay node ids](https://dylansnel.github.io/DDDToolkit/docs/graphql.md#relay-node-ids), where it is a node id that silently carries nothing. [DDD00033](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00033) is about the [generated integration event registration](https://dylansnel.github.io/DDDToolkit/docs/integration-events.md#registered-when-the-module-compiles), where it is an outbound class or a handler that is never registered. [DDD00034](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00034) to [DDD00037](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00037) are about [event names](https://dylansnel.github.io/DDDToolkit/docs/domain-events.md#stable-names), where it is a stored row or a message read back as the wrong type, or as the wrong shape. [DDD00038](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00038) to [DDD00041](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00041) are about [row access rules](https://dylansnel.github.io/DDDToolkit/docs/row-level-security.md#row-access-rules-written-in-c), where it is a rule the database enforces differently from the C# that states it, or not at all. That split is what the numbering is for. DDD00001 to DDD00019 are reserved for "the generator could not do what you asked", and DDD00020 upwards for rules about the model. Severity does not follow the split. [DDD00020](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00020) and [DDD00027](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00027) are errors even though they sit in the second group, because in both the generator drops the member rather than emitting something wrong, and a warning would leave you with a rule that silently never runs. The table above is the complete list: DDD00012 and DDD00014 to DDD00019 have never been assigned, and the gaps are room to grow rather than something that was removed. Ids are stable and are never reused, so a number that is missing here is missing from the compiler too. --- ## DDD00001 **Value objects must be records.** ```csharp [ValueObject] public partial class Address { } // DDD00001 ``` `[ValueObject]` and `[SingleValueObject]` generate structural equality members and an always-valid twin that derives from your type. Both require a reference record. ```csharp [ValueObject] public partial record Address { } ``` Nothing is generated for the type until it is a record, so expect follow-on errors about missing members until you fix this one. --- ## DDD00002 **Entities must be classes.** ```csharp [AggregateRoot] public partial record Order { } // DDD00002 ``` Entities have identity, not value semantics, and derive from a generated base class. A record would give you value equality across all properties, which is wrong for an entity: an order whose status changed is still the same order. ```csharp [AggregateRoot] public partial class Order { } ``` --- ## DDD00003 **Entity ids must be records.** ```csharp [EntityId] public partial class OrderId { } // DDD00003 ``` Identifiers rely on record equality. Use a record struct for an allocation-free id, or a record class when you need inheritance or the always-valid twin: ```csharp [EntityId] public readonly partial record struct OrderId; [EntityId] public partial record OrderId; ``` See [Identifiers](https://dylansnel.github.io/DDDToolkit/docs/identifiers.md) for which to choose. --- ## DDD00004 **Entity id structs should be readonly.** ```csharp [EntityId] public partial record struct OrderId; // DDD00004, generation still happens ``` A warning, not an error: the identifier is generated either way. Adding `readonly` states that the id cannot be mutated after construction and lets the compiler skip defensive copies when the struct is passed around. ```csharp [EntityId] public readonly partial record struct OrderId; ``` --- ## DDD00005 **DDDToolkit types must be partial.** ```csharp [AggregateRoot] public class Order { } // DDD00005 ``` The generator adds a second declaration of your type, which requires `partial`. Add the keyword: ```csharp [AggregateRoot] public partial class Order { } ``` --- ## DDD00006 **DDDToolkit types cannot be generic.** ```csharp [EntityId] public readonly partial record struct Reference; // DDD00006 public partial class Repository { [AggregateRoot] public partial class Entry { } // DDD00006, through its container } ``` A type nested in a non-generic container is fine and generates normally. The generated members have to name your type from places that cannot see a type parameter. A struct identifier carries `[JsonConverter(typeof(Reference.SystemTextJsonConverter))]`, and an attribute argument may not name an open generic. The `AddConverters` and `AddGraphQlRuntimeBindings` registrations live outside the type and cannot name it at all. The rule covers a type nested inside a generic type for the same reason: the type parameter is still in scope, so the same references are still unspeakable. Give the type a concrete identity instead: ```csharp [EntityId] public readonly partial record struct OrderReference; ``` --- ## DDD00007 **The generated identifier name is already taken.** ```csharp public sealed class OrderId { } // something else, in the same namespace [AggregateRoot("ORD")] public partial class Order { } // DDD00007 ``` `[AggregateRoot]` and `[Entity]` name a raw value, so the toolkit generates the identifier as well, called `OrderId`. Another type of that name in the same namespace or containing type would be a duplicate definition. Without this diagnostic the compiler would report CS0101 against generated code you never wrote. Either point the attribute at the identifier you already have: ```csharp [EntityId("ORD")] public readonly partial record struct OrderId; [AggregateRoot] public partial class Order { } ``` Or rename whichever of the two types should not be called `OrderId`. The one exception is a `partial record struct` of that name with no `[EntityId]` on it. That is taken as your own half of the generated identifier and reports nothing, which is how you add members to it: ```csharp public readonly partial record struct OrderId { public string Short => Value.ToString("N")[..8]; } ``` Such a part must be `partial`, must be a `record struct`, and must not state an accessibility different from the entity's. A part that states `internal` where the generated part says `public` would be CS0262, so it reports DDD00007 instead. Nothing is generated for the entity until the clash is gone, so expect follow-on errors about its missing base class. --- ## DDD00008 **The identifier type argument is not supported.** ```csharp public sealed class Money { } [AggregateRoot] public partial class Order { } // DDD00008 ``` The type argument of `[Entity]` and `[AggregateRoot]` is one of two things: an identifier you already have, meaning any type carrying `[EntityId]` or implementing `IEntityId`, or the raw value an identifier should wrap. A reference type that is not a `string` is neither. It can be null and it is not copied by value, and an identifier has to be both. ```csharp [AggregateRoot("ORD")] // a value the toolkit can wrap public partial class Order { } [AggregateRoot] // an identifier you declared yourself public partial class Order { } ``` `Guid`, `int`, `long`, `string`, `DateOnly` and your own structs all work. `Guid?` does not: an optional identifier is `OrderId?`, not an identifier over a nullable value. Nothing is generated for the entity until the type argument is one of the two. --- ## DDD00009 **A type is either an entity or an aggregate root.** ```csharp [Entity] [AggregateRoot] public partial class Widget { } // DDD00009 ``` An aggregate root is a consistency boundary. A child entity lives inside one. A type cannot be both, and the two attributes generate different base types for the same declaration. Keep whichever describes the type. Use `[AggregateRoot]` for something you load, save and reference from elsewhere, and `[Entity]` for something that only exists inside one aggregate. See [Entities and aggregates](https://dylansnel.github.io/DDDToolkit/docs/entities-and-aggregates.md). Before this diagnostic existed, both attributes on one class made the generator throw and contribute nothing at all, leaving only a `CS8785` about a crashed generator and no hint about the cause. --- ## DDD00010 **Value object properties must use protected setters.** ```csharp [ValueObject] public partial record Address { public string City { get; init; } // DDD00010 } ``` A record with a publicly writable property can be cloned with `with` into a state that never passed validation: ```csharp var invalid = address with { City = "" }; ``` Making the setter `protected` keeps `with` available inside the type and its always-valid twin while closing it to callers: ```csharp public string City { get; protected init; } ``` The copy does not go through validation, and on the always-valid twin that produces a `ValidAddress` holding an invalid value, even from code that only knows about `Address`. [Why `with` is closed to callers](https://dylansnel.github.io/DDDToolkit/docs/value-objects.md#why-with-is-closed-to-callers) goes through the mechanics, and why checking the copy at the `with` is not possible. A code fix makes that change for you: `protected init`, or `private protected init` on an `internal` property. Callers that need a changed copy use the generated [`With(...)`](https://dylansnel.github.io/DDDToolkit/docs/value-objects.md#changing-a-value-with) instead of `with`. A positional record does not report DDD00010. Its parameters would become `public init` properties, so the generator declares them itself as `protected init`; see [Positional records](https://dylansnel.github.io/DDDToolkit/docs/value-objects.md#positional-records). --- ## DDD00011 **Value object properties must use init setters.** ```csharp [ValueObject] public partial record Address { public string City { get; protected set; } // DDD00011 } ``` A non-init setter lets any deriving type change the value after construction. Value objects are immutable, so the setter must be `init`: ```csharp public string City { get; protected init; } ``` DDD00010 and DDD00011 are separate rules and a property with a plain `public set` reports both. The fix for both is `protected init`, and the code fix described under [DDD00010](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00010) applies it. --- ## DDD00013 **Value objects cannot be sealed.** ```csharp [SingleValueObject] public sealed partial record EmailAddress { } // DDD00013 ``` The generator emits `ValidEmailAddress`, which derives from `EmailAddress`. A sealed record cannot be derived from, so the twin cannot exist. Remove `sealed`. Record structs are never affected: they are implicitly sealed but get no twin, since a struct cannot be derived from at all. --- ## DDD00020 **Generated collection properties must be get-only.** ```csharp [AggregateRoot] public partial class Order { public partial IReadOnlyList Lines { get; set; } // DDD00020 } ``` The generator implements the property as a read-only view over a private backing field. A setter would let a caller replace the whole collection and bypass the aggregate's invariants, which is the thing the read-only view exists to prevent. ```csharp public partial IReadOnlyList Lines { get; } ``` Mutate through the generated field instead: ```csharp public void AddLine(OrderLine line) => _lines.Add(line); ``` See [Entities and aggregates](https://dylansnel.github.io/DDDToolkit/docs/entities-and-aggregates.md#read-only-collections). --- ## DDD00021 **Reference another aggregate by its id.** ```csharp [AggregateRoot] public partial class Customer { } [AggregateRoot] public partial class Order { public Customer Buyer { get; private set; } // DDD00021 public IReadOnlyList Watchers { get; } // DDD00021 } ``` Hold the other aggregate's id instead: ```csharp [AggregateRoot] public partial class Order { public CustomerId Buyer { get; private set; } public void PlaceFor(Customer customer) => Buyer = customer.Id; } ``` Each aggregate is a separate loading and consistency boundary. A field or property typed as another root pulls that root inside this one: Entity Framework builds a navigation from it, one save then writes two roots, and neither `Version` guards its own aggregate any more. See [Entities and aggregates](https://dylansnel.github.io/DDDToolkit/docs/entities-and-aggregates.md#reference-other-aggregates-by-id) for the longer version. A warning, not an error. The code compiles, everything is still generated, and a team that disagrees can turn the rule off for a project: ```xml $(NoWarn);DDD00021 ``` `#pragma warning disable DDD00021` around one property does **not** work, and neither does `[SuppressMessage]`. That is a limitation of source generators rather than a choice: a generator reports its diagnostics with a location rebuilt from a file path and holding no syntax tree, which is what keeps the pipeline cacheable, and the compiler has no tree to match a pragma against. `NoWarn` is read from the compilation options, so it reaches them. The same is true of every DDD diagnostic; this is the only one you are likely to want to switch off. ### What reports and what does not | | Reports | |---|---| | A field or property typed as another root | Yes | | A collection, array or dictionary holding another root | Yes | | A `Customer?` property | Yes | | A root holding another instance of its own type, such as `Employee.Manager` | Yes | | A child `[Entity]` of the same aggregate | No | | A child entity navigating back to the root that owns it | No | | A property typed as the other aggregate's id | No | | A method parameter or return type | No | | A `static` member | No | Two rows deserve a word. **The back-navigation.** A child entity may hold the root that owns it, which is the inverse navigation Entity Framework wants: ```csharp [AggregateRoot] public partial class Order { public partial IReadOnlyList Lines { get; } } [Entity] public partial class OrderLine { public Order Order { get; private set; } // allowed } ``` The exemption is checked, not assumed: `Order` has to hold `OrderLine` back, through a collection or a single property, and the reference from the child has to be single valued. A child entity pointing at some other root still reports, because that reference does widen the boundary. **Methods.** `order.PlaceFor(customer)` takes the other root, reads what it needs and stores nothing, which is how two aggregates are meant to cooperate. Entity Framework cannot see a method either, so no navigation comes of it. The rule is about what an aggregate *holds*. The rule reads the declared type of a member, so a property typed as an interface that an aggregate root happens to implement is not recognised, and neither is `object`. --- ## DDD00022 **Use only what another module publishes.** ```csharp // Crm.csproj: [assembly: Module("Crm")] namespace Crm; [AggregateRoot] public partial class Customer { } // Sales.csproj: [assembly: Module("Sales")] namespace Sales; public sealed class OrderReport { public string Describe(Customer customer) => customer.Name; // DDD00022 } ``` Either publish the type, in the module that owns it: ```csharp namespace Crm; [ModuleContract] public sealed record CustomerSummary(CustomerId Id, string Name); ``` or go through something already published, which for an aggregate is usually its id and its integration events: ```csharp namespace Sales; public sealed class OrderReport { public string Describe(CustomerSummary customer) => customer.Name; } ``` A module is an assembly carrying `[assembly: Module("Name")]`. Its published contract is every type marked `[ModuleContract]`, every type marked `[IntegrationEvent]`, and anything nested inside one of those. Everything else the assembly declares is the owning team's business, `public` or not. The rule is silent unless both assemblies declare a module, so it reports nothing in a codebase that has not opted in, and never against the framework, a NuGet package or a shared kernel. Two assemblies that declare the same module name are one module and no boundary runs between them. It reports where you *name* another module's unpublished type: a parameter, a field, a base type, a generic argument, an attribute, a `typeof`, a `new`, a static call, a `using` alias. It cannot see a type you never name (`var`), an extension method called on an instance, an inherited member, or anything resolved by reflection. [Modules](https://dylansnel.github.io/DDDToolkit/docs/modules.md#what-the-analyzer-cannot-catch) has the full list, which is worth reading before you trust the rule. A warning, for the same reason as DDD00021: it states a design decision, and a team adopting modules wants the list before it has to fix it. This one comes from an analyzer rather than from a generator, so `#pragma warning disable DDD00022`, `[SuppressMessage]` and `NoWarn` all work, and `$(WarningsAsErrors);DDD00022` turns it into a build break once the list is empty. --- ## DDD00023 **Do not hold another module's entity.** ```csharp // Sales [AggregateRoot("ORD")] public partial class Order { public Customer Buyer { get; private set; } // DDD00023, Customer belongs to Crm } ``` Hold the other module's published id, and react to what it publishes: ```csharp [AggregateRoot("ORD")] public partial class Order { public CustomerId Buyer { get; private set; } public void PlaceFor(CustomerId customer) => Buyer = customer; } ``` A property typed as another module's entity is a navigation. Entity Framework maps it, a query in one module loads rows owned by the other, and one `SaveChanges` writes into both inside one transaction. The two modules can then no longer be tested, migrated or separated on their own, and nothing in the code looks wrong. Publishing the entity does not help and does not silence this rule. `[ModuleContract]` says you may name a type; it cannot say you may make that type part of your own transaction. ### What reports and what does not | | Reports | |---|---| | A field or property typed as another module's `[Entity]` or `[AggregateRoot]` | Yes | | A collection, array or dictionary holding one | Yes | | One that the other module publishes with `[ModuleContract]` | Yes | | A property typed as the other module's id | No | | An entity of the same module | No | | An entity of an assembly that declares no module | No | | A method parameter or return type | No | | A `static` member | No | | The generated backing field of a collection property | No, the property is reported instead | Like DDD00022 this is a warning and comes from an analyzer, so pragmas, `[SuppressMessage]`, `NoWarn` and `WarningsAsErrors` all work on it. Holding another module's *aggregate root* reports [DDD00021](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00021) as well: one says hold the id, the other says do not reach across the boundary, and both are answered by the same edit. See [Modules](https://dylansnel.github.io/DDDToolkit/docs/modules.md) for the longer version. --- ## DDD00024 **An invariant must be nested inside the entity it is about.** ```csharp // Ordering/MustHaveLines.cs public sealed class MustHaveLines : IInvariant // DDD00024 { public string Code => "ORDER_HAS_NO_LINES"; public InvariantFailure? Check(Order order) => order.Lines.Count == 0 ? "..." : null; } ``` Nest it inside the entity it judges, in a part of your own: ```csharp // Ordering/Invariants/MustHaveLines.cs public partial class Order { public sealed class MustHaveLines : IInvariant { // the same body } } ``` An entity runs the rules it finds among its own nested types. That is what lets a rule read the entity's private state, and it is what keeps discovery free of a scan over the whole compilation. A rule declared anywhere else compiles, reads well, passes the unit test you wrote for it, and never runs on a save. It is the one failure the invariants feature is built to make impossible to ship unnoticed, which is why this fires at all. This is the only one of the four that comes from a real analyzer rather than from the generator, and for a plain reason: a type outside an entity is by definition not among any entity's nested types, so only a pass over the compilation can see it. That also means `#pragma warning disable DDD00024`, `[SuppressMessage]`, `NoWarn` and `WarningsAsErrors` all work on it, which the three below cannot offer. A warning rather than an error, because an author who really did mean to hand the rule to something else of their own should be able to say so and move on. ### What reports and what does not | | Reports | |---|---| | A top-level type implementing `IInvariant` | Yes | | One nested inside a class that is not an `[Entity]` or `[AggregateRoot]` | Yes | | One that takes its `Check` from a base class | Yes, the concrete type is the rule | | An `abstract` base shared between several rules | No, it is not a rule | | An interface deriving from `IInvariant` | No | | An open generic rule, such as `Rule` | No | | One nested inside the wrong entity | No, that is [DDD00025](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00025) | See [Invariants](https://dylansnel.github.io/DDDToolkit/docs/invariants.md#why-a-rule-is-a-nested-type). --- ## DDD00025 **An invariant is nested inside a type it is not about.** ```csharp public partial class Order { public sealed class LineMustCostSomething : IInvariant // DDD00025 { public string Code => "LINE_IS_FREE"; public InvariantFailure? Check(OrderLine line) => line.Price <= 0 ? "..." : null; } } ``` Nest it inside the type it is about instead: ```csharp public partial class OrderLine { public sealed class MustCostSomething : IInvariant { } } ``` An entity runs the nested rules that are about *itself*. This one is in the right kind of place and still never runs, which makes it harder to spot than [DDD00024](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00024) rather than easier. In practice the type argument was copied from a rule next to it. A rule about a base class or an interface of the entity is accepted and reported by nothing: `IInvariant` is contravariant in `T`, so `IInvariant` nested inside `Order` runs on an `Order` perfectly well. Only a type argument the entity cannot be handed to is reported. Reported by the generator, so `NoWarn` reaches it and a pragma does not. The same is true of DDD00026 and DDD00027; [DDD00021](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00021) explains why. --- ## DDD00026 **Two invariants of one entity share a code.** ```csharp public partial class Order { public sealed class MustHaveLines : IInvariant { public string Code => "ORDER_INVALID"; } public sealed class MustStayWithinTheCreditLimit : IInvariant { public string Code => "ORDER_INVALID"; // DDD00026 } } ``` The code exists so that a caller can act on a broken rule without matching on its message, which is the difference between a rule you can branch on and a string you can only display. Two rules answering to one code takes that back: `GetInvariantViolations()` returns two entries the caller cannot tell apart, and the branch that was supposed to catch one catches both. Both rules are still generated and both still run. Nothing about this stops compiling; what stops working is the thing the code was for. Only the second rule is reported, and only codes this pass can read as a constant are compared at all: | `Code` written as | Compared | |---|---| | `=> "ORDER_INVALID"` | Yes | | `{ get; } = "ORDER_INVALID"` | Yes | | `=> ViolationCode`, a `const` | Yes, the constant's value | | `=> $"ORDER_{Reason}"` or anything computed | No | | Inherited from a base class as a constant | Yes | A code built at run time is left alone rather than guessed at, because a wrong guess would report two rules as sharing a code they do not share. --- ## DDD00027 **An invariant needs an accessible parameterless constructor.** ```csharp public partial class Order { public sealed class MustStayWithinTheCreditLimit : IInvariant { // DDD00027, the generated code inside Order cannot write new MustStayWithinTheCreditLimit() public MustStayWithinTheCreditLimit(decimal limit) => _limit = limit; } } ``` The generator creates one instance of every rule per entity type and reuses it for every check, so a rule has to be constructible without arguments, and has to be stateless for that reuse to be sound. The limit in the example belongs to the entity, not to the rule, and the rule reads it off the `Order` it is handed: ```csharp public InvariantFailure? Check(Order order) => order.Total > order.CreditLimit ? $"An order may not exceed {order.CreditLimit}." : null; ``` An error rather than a warning. Nothing is generated for a rule the generated code cannot build, so a warning would leave the rule silently dropped, which is the exact silence the other three exist to prevent. The entity itself is still generated and everything else about it still works: you get this one error rather than a page of follow-on ones. Accessibility is asked of the compiler rather than read off the modifiers, because the two do not line up here. A rule nested inside the entity may be `private` and is still reachable from the generated code, which is written inside that same entity; a `private` constructor on it is not. | | Reports | |---|---| | A constructor taking parameters, and no other | Yes | | A `private` constructor on a rule nested in the entity | Yes | | No constructor at all, so the implicit public one | No | | A `private` rule with an accessible constructor | No | | An `abstract` base or an open generic rule | No, neither is a rule | See [Invariants](https://dylansnel.github.io/DDDToolkit/docs/invariants.md#why-a-rule-is-a-nested-type). --- ## DDD00028 **A key part belongs on an entity or aggregate root.** ```csharp [ValueObject] public partial record Address { [KeyPart] public RegionId Region { get; protected init; } // DDD00028 } ``` `[KeyPart]` puts a property into a primary key ahead of the identifier. Only an `[AggregateRoot]` or an `[Entity]` has an identifier and a key, so anywhere else the attribute would do nothing, silently. Remove it, or move the property to the entity that is keyed on it. See [Composite keys](https://dylansnel.github.io/DDDToolkit/docs/composite-keys.md). --- ## DDD00029 **A key part should not have a public setter.** ```csharp [AggregateRoot] public partial class Project { [KeyPart] public RegionId RegionId { get; set; } // DDD00029 } ``` Set it once, in the constructor, and make it get-only: ```csharp [KeyPart] public RegionId RegionId { get; } ``` A key part is part of the primary key, and a primary key does not change once the row exists: Entity Framework refuses to save a modified key value, and every owned child's foreign key carries the same value. `{ get; }`, `private set`, `protected set` and `init` are all fine; only a public, non-init setter reports. A warning: everything is still generated. --- ## DDD00030 **Declare all key parts of a type in one file.** ```csharp // Project.cs [AggregateRoot] public partial class Project { [KeyPart] public RegionId RegionId { get; } } // Project.Period.cs public partial class Project { [KeyPart] public int Period { get; } // DDD00030, reported on Project } ``` Move them into one part of the class, in the order you want the key: ```csharp public partial class Project { [KeyPart] public RegionId RegionId { get; } [KeyPart] public int Period { get; } } ``` Key parts join the primary key in declaration order. Within one file that order is plain; between the files of a partial class there is none, only the order the compiler happens to read the files in, and a key whose column order could change with a file rename is not a key you want. Nothing is generated for the type until the key parts are together. --- ## DDD00031 **A [SupabaseMigrations] factory must be one the build can create.** Reported in the project that turns the Supabase export on (``), normally the host, about a factory in a module it references. ```csharp [SupabaseMigrations] internal sealed class OrderingContextFactory : IDesignTimeDbContextFactory // DDD00031: the host cannot see it ``` The build creates every marked factory from code generated into the host, as `SupabaseMigrationSource.For()`. That needs a public, non-abstract, non-generic class with a public parameterless constructor, implementing `IDesignTimeDbContextFactory` for a context the host can see too: ```csharp [SupabaseMigrations] public sealed class OrderingContextFactory : IDesignTimeDbContextFactory { public OrderingContext CreateDbContext(string[] args) { ... } } ``` The message names what is missing. A factory that fails is left out of the export, which is why this is an error: a module whose migrations were silently skipped would be found by a failing deployment, or by a branch database without its tables, instead of by the build. ## DDD00032 **Do not ask HotChocolate's generator for a toolkit identifier's node id serializer.** ```csharp graphql.AddNodeIdValueSerializerFrom(); // DDD00032 ``` `AddNodeIdValueSerializerFrom()` is intercepted by HotChocolate's own generator, which writes a serializer from the properties `T` declares in source. A toolkit identifier's `Value` is written by the toolkit's generator, and source generators do not see each other's output, so HotChocolate's finds no property and writes a serializer that stores nothing. Every order's node id becomes `Order:` and reads back as an empty `OrderId`. Nothing fails to compile and nothing throws. Remove the call. `Add{Module}GraphQlRuntimeBindings()` already registers a serializer for every identifier over a `Guid`, `string`, `int`, `long` or `short`, in HotChocolate's own format; see [Relay node ids](https://dylansnel.github.io/DDDToolkit/docs/graphql.md#relay-node-ids). --- ## DDD00033 **The generated integration event registration must be able to construct the class.** ```csharp public sealed class PublishOrderPlaced : IOutboundIntegrationEvent { private PublishOrderPlaced() { } // DDD00033: no constructor this assembly can call // ... } ``` The generated `Add{Module}IntegrationEvents()` registers every outbound class and every handler in the module by writing `new` for it, and takes each constructor parameter from the scope the message is delivered in. That only works for a class with one accessible constructor with the most parameters, whose parameters are not `ref`, `out` or `params`, and whose parameter types the assembly can see. The message says which of those failed. A class the registration cannot construct is left out of it, so its domain event is never published, or its contract is never handled. That is a warning rather than silence because nothing else would tell you. Give the class a constructor the registration can call, or register it by hand. --- ## DDD00034 **An event's class name and its Version disagree.** ```csharp [IntegrationEvent(Version = 3)] // DDD00034, at Version = 3 public sealed record OrderPlacedV2(OrderId OrderId); ``` A class name that ends in `V` and a number is that version of its event by convention, and `Version` on `[IntegrationEvent]` states one explicitly. The stated version wins wherever the version is read: the outbox, the published message, the generated registration. So this event is version 3, and the `V2` in its name is ignored. Nothing is ambiguous, which is why this is a warning and not an error, but a name that says 2 about an event that is 3 is how somebody ends up writing an upcaster for the wrong version. There are two fixes. The first renames the class to the version it is, `OrderPlacedV3`, everywhere it is used; the event does not change. The second removes `Version = 3`, which makes the event version 2, the name's, for when the name was right and the attribute was not. See [Versions are in the class name](https://dylansnel.github.io/DDDToolkit/docs/domain-events.md#versions-are-in-the-class-name). --- ## DDD00035 **An event's class name ends in something that is not a version.** ```csharp public sealed record OrderPlacedV0(OrderId OrderId) : DomainEvent; // DDD00035 public sealed record OrderPlacedV01(OrderId OrderId) : DomainEvent; // DDD00035 ``` Every event's class name is read for a version suffix. Versions start at 1 and are written without leading zeros, so `V0` and `V01` cannot be one, and reading them as part of the name instead would give this event a version rule of its own. Rename the class: `V1` for a first version, or a name that does not end in `V` and digits. Digits that do not follow a `V`, as in `Level2Reached`, are part of the name and fine. --- ## DDD00036 **Two events of one module share a name and version.** ```csharp [assembly: Module("Ordering")] namespace Ordering.Orders { public sealed record OrderPlaced(OrderId OrderId) : DomainEvent; } // DDD00036 namespace Ordering.Returns { public sealed record OrderPlaced(OrderId OrderId) : DomainEvent; } // DDD00036 ``` An event is found by its name and version when a stored row or a delivered message is read back, and both of these are `ordering.order-placed` version 1. The registry would refuse the second at start-up; this refuses it when the module compiles, on both classes, because neither is more wrong than the other. The code fix pins another name on the class you invoke it on, made from its namespace or containing type: `[DomainEventName("ordering.returns-order-placed")]` on a domain event, the name in its `[IntegrationEvent("...")]` on a contract. A class whose name is already pinned is offered nothing, since that name was chosen by hand. Renaming one of the classes works as well. Two domain events are compared with each other, and two contracts with each other. A domain event and the contract it is published as share a name on purpose, and so do the versions of one event; neither is reported. The toolkit does not make a name unique from the namespace by itself, because that name would change when the class moved. See [Two events with one name](https://dylansnel.github.io/DDDToolkit/docs/domain-events.md#two-events-with-one-name). --- ## DDD00037 **Two event names give one constant name.** ```csharp [DomainEventName("ordering.order-placed")] public sealed record OrderPlaced(OrderId OrderId) : DomainEvent; [DomainEventName("ordering.order.placed")] // DDD00037, on both public sealed record PlacedOrder(OrderId OrderId) : DomainEvent; ``` Every name gets a constant in the generated `{Module}EventNames`, named after the name without its module and in PascalCase: both of these would be `OrderingEventNames.OrderPlaced`. Whichever name the constant held, code that reached for it meaning the other event would bind a topic or a test to the wrong event and never find out, so this is an error, on the events of both names. Pin one of the names to something that reads differently. Until then the first name in ordinal order keeps the constant, only so that code already using it does not add errors of its own to this one. ## DDD00038 **A row access rule is a static partial class with one Allows method.** ```csharp [RowAccess(RowOperations.Read)] public static class ACustomerSeesTheirOrders // DDD00038: not partial { public static bool Allows(Order order) => order.PlacedBy == null; // DDD00038: no Caller } ``` The generator writes the rule's SQL into another part of the class, so the class is `static partial`. And it translates `Allows`, which is a static method taking the aggregate the rule is about and a `Caller`, returning `bool`, with a single expression for a body: after `=>`, or as its only `return` statement. Nothing is generated until the rule has that shape, and a rule without SQL never reaches the database. ## DDD00039 **A row access rule can only say what the database can check.** ```csharp public static bool Allows(Order order, Caller caller) => order.Team!.StartsWith("north"); // DDD00039, on the call ``` A rule becomes a condition the database evaluates for every row, so it can use the aggregate's own properties, constants written in the rule, and the caller: `caller.UserId`, `caller.IsSignedIn`, `caller.Role` and `caller.Claim("app_metadata.team")`. It can compare them, with `==`, `!=`, `<`, `<=`, `>` and `>=`, and combine the comparisons with `&&`, `||` and `!`. A method call, a local variable, another object or the clock has no column and no claim to become, and leaving it out would make the database answer differently from the C# method, so it is an error on the part that cannot be translated. Store what the rule needs as a property of the aggregate, or put it in the caller's `app_metadata`. ## DDD00040 **A row access rule guards an aggregate root.** ```csharp [RowAccess(RowOperations.Read)] // DDD00040 public static partial class LinesOfBigOrders { ... } ``` An aggregate is read and changed as a whole. A rule on one of its entities could hide some of an order's lines and not the order, and Entity Framework would load half an aggregate whose invariants then check half the data. Write the rule for the root. The export gives the tables of the aggregate's entities a policy that follows it: a line is visible exactly when its order is. --- ## DDD00041 **A row access rule reads the aggregate's entities through an access function.** ```csharp [RowAccess(RowOperations.Read)] public static partial class MembersSeeTheirProjects { public static bool Allows(Project project, Caller caller) => project.Members.Any(member => member.UserId == caller.UserId); // DDD00041 } ``` The tables of an aggregate's entities have policies that ask the aggregate's table whether their row is visible. A policy on the aggregate's table that read those tables would ask itself, and Postgres stops the query with infinite recursion. Put the question in an [access function](https://dylansnel.github.io/DDDToolkit/docs/row-level-security.md#asking-the-aggregates-entities-access-functions), which runs as its owner and reads the entities without their policies, and call it from the rule: ```csharp [AccessFunction("projects.is_member")] public static partial class ProjectMembership { public static bool Allows(Project project, Caller caller) => project.Members.Any(member => member.UserId == caller.UserId); } [RowAccess(RowOperations.Read)] public static partial class MembersSeeTheirProjects { public static bool Allows(Project project, Caller caller) => ProjectMembership.Allows(project, caller); } ``` --- ## Building the model fails: the owned type must carry the key part Not a compiler diagnostic, because it depends on how the Entity Framework model is put together, but it is reported as early as that allows: when the context builds its model, before the first query. ``` 'Project' is keyed on 'RegionId', so the foreign key of its owned 'Milestone' (through 'Project.Milestones') must carry it too, but 'Milestone' has no such property. ... ``` `Project` has `[KeyPart] RegionId`, so every table it owns carries `RegionId` in its foreign key, and `Milestone` has nowhere to keep it. Give the child the property and set it from the parent: ```csharp [Entity] public partial class Milestone { public Milestone(RegionId regionId, MilestoneId id) : base(id) => RegionId = regionId; [KeyPart] public RegionId RegionId { get; } } ``` The property must have the same name and the same type as the owner's. If the child really should not carry it, configure that ownership's foreign key yourself with `OwnsMany(...).WithOwner().HasForeignKey(...)`; the convention leaves explicit configuration alone. --- ## Nothing was generated and there is no diagnostic Check, in order: 1. **The generator package is referenced.** `DDDToolkit` brings the core generators; the Entity Framework, FluentValidation and HotChocolate generators come with their own packages. 2. **The type is `partial`** and the attribute is the generic one, `[EntityId]` rather than a hand-written attribute with the same name. 3. **The build output**, with `EmitCompilerGeneratedFiles` turned on, as described in [Getting started](https://dylansnel.github.io/DDDToolkit/docs/getting-started.md#see-the-generated-code). If a generator throws, the compiler reports it as CS8785 or CS8784 rather than as a DDD diagnostic. That is a bug in the toolkit; please report it with the declaration that triggered it. # Performance [Identifiers](https://dylansnel.github.io/DDDToolkit/docs/identifiers.md) tells you to prefer the struct form of an identifier, and argues it from memory layout. This page is the measurement behind that advice, including the two places where the measurement does not agree with it. The benchmarks live in `Benchmarks/DDDToolkit.Benchmarks` and use [BenchmarkDotNet](https://benchmarkdotnet.org). Run them yourself with: ``` dotnet build Benchmarks/DDDToolkit.Benchmarks/DDDToolkit.Benchmarks.csproj -c Release Benchmarks/DDDToolkit.Benchmarks/bin/Release/net10.0/DDDToolkit.Benchmarks.exe --filter * ``` Both identifiers wrap a `Guid` and carry the same prefix, so nothing but the form differs: ```csharp [EntityId("ORD")] public readonly partial record struct StructOrderId; [EntityId("ORD")] public partial record RecordOrderId { public static RecordOrderId From(Guid value) => new(value); } ``` ## The machine Every number below came from one run on one machine. Treat the ratios as the result and the absolute times as trivia: yours will differ. ``` BenchmarkDotNet v0.15.8, Windows 11 (10.0.26200.9445/25H2) 12th Gen Intel Core i9-12900K 3.20GHz, 1 CPU, 24 logical and 16 physical cores .NET SDK 10.0.301 .NET 10.0.9 (10.0.9, 10.0.926.27113), X64 RyuJIT x86-64-v3 GC=Concurrent Workstation ``` The Entity Framework benchmarks run against SQLite in memory. That is the fastest database a benchmark can talk to, which is the point: it makes the share of a round trip that belongs to Entity Framework and to the identifier as large as it will ever be. On a real server across a network the same difference is smaller, never larger. ## Creating identifiers 10,000 identifiers built from values already in hand, into an array. `RawGuid` is the floor: the same array with no wrapper on it. | | Mean | Allocated | |---|---|---| | `Guid` | 42.89 μs | 156.29 KB | | Struct id | 42.67 μs | 156.29 KB | | Record id | 58.54 μs | 625.02 KB | The struct id is the raw `Guid` array, to the byte and inside the measurement error. The record id costs **4 times the memory**: 64 bytes per identifier against 16, which is an 8-byte reference in the array plus a 56-byte object holding the object header, the `Guid`, the prefix reference and the two validation bookkeeping fields it inherits from `ValueObject`. That is the same 64 bytes [Identifiers](https://dylansnel.github.io/DDDToolkit/docs/identifiers.md#struct-or-record) counts. The two fields at the end are easy to forget: the record form carries the value object validation state, two fields per identifier that an identifier never uses. The time difference here (1.37x) is mostly garbage collection, and it is the least reliable number on this page: the record run had a standard deviation of 13.8 μs against a mean of 58.5 μs, because 625 KB per operation puts the GC in the middle of the measurement. ## Walking a collection 10,000 identifiers in an array, compared against a target that matches the last element, so every comparison runs. | | Mean | Ratio | Allocated | |---|---|---|---| | Struct id, compare each | 3.37 μs | 1.00 | none | | Record id, compare each | 8.46 μs | 2.51 | none | | Struct id, read `.Value` | 7.23 μs | 2.15 | none | | Record id, read `.Value` | 8.63 μs | 2.57 | none | Reading the value through a reference costs about 20%, which is the dereference the page predicts. Comparing costs 2.5 times, which is the same dereference paid twice plus the call. **This measurement used to read 69.6, with 1,440,000 bytes allocated.** A record identifier inherited equality from `ValueObject`, which compares `GetEqualityComponents()` sequences: ```csharp return Enumerable.SequenceEqual(GetEqualityComponents(), other.GetEqualityComponents()); ``` Two iterator objects, two boxed `Guid`s and a LINQ call, for every comparison. That is a reasonable default for a value object with several components and absurd for an identifier wrapping one `Guid`, and nobody had measured it. The generators now emit a direct comparison of `Value` for entity ids and single value objects, which yields the same answer because both types have exactly one component. The numbers above are from after that change. ## Reading a read-only collection `order.Lines` hands out a read-only view over the generated backing field. `List.AsReadOnly()` is `new ReadOnlyCollection(this)` and caches nothing, so a property written as `=> _lines.AsReadOnly()` builds a wrapper on every read. The generator holds the view in a second field instead. Sixteen reads of one property: | | Time | Allocated | |---|---|---| | A wrapper per read | 68.2 ns | 384 B | | The wrapper held in a field | 10.5 ns | 0 B | | The bare list, no protection | 5.2 ns | 0 B | 384 bytes is exactly 16 wrappers of 24. The element count does not change any of it: at 4 elements and at 256 the numbers are the same, because the wrapper wraps rather than copies. The third row is not an option, only the floor. Returning `_lines` allocates nothing and satisfies `IReadOnlyList`, but a caller can cast it back to `List` and mutate the aggregate around every invariant it has. The 5 nanoseconds between the second row and the third are what that protection costs once it is no longer rebuilt per read. ## Dictionary and set lookup 1,000 hits against a container holding 10,000 entries. | | Mean | Ratio | Allocated | |---|---|---|---| | `Dictionary` keyed by struct id | 3.53 μs | 1.00 | none | | `Dictionary` keyed by record id | 5.35 μs | 1.52 | none | | `HashSet` of struct ids | 3.55 μs | 1.01 | none | | `HashSet` of record ids | 4.92 μs | 1.40 | none | Every probe pays for a hash and at least one equality check, so this tracked the defect above exactly: it used to read 17.5 and 21.1, with 272,000 bytes allocated by something whose whole job is to be a fast index. With the direct comparison in place the record form costs about half as much again as the struct form, which is the dereference and the call, and allocates nothing. ## Equality and hashing on their own One comparison and one hash, undiluted. | | Mean | Allocated | |---|---|---| | Struct id `==` | 0.20 ns | none | | Record id `==` | 0.23 ns | none | | Struct id `GetHashCode` | not measurable | none | | Record id `GetHashCode` | 0.19 ns | none | Both forms are now effectively free, and the numbers here are at the edge of what the harness can resolve: BenchmarkDotNet reports the struct hash as *"the method duration is indistinguishable from the empty method duration"*, so there is no honest figure to quote for it. Before the generators emitted a direct comparison, the record form measured 30.47 ns and 144 bytes for an equality check and 23.81 ns and 128 bytes for a hash. Those two lines were the whole of the 17x-to-70x gap in the two sections above. ## Text `ToString` and `Parse`, the operations at the edges of the system. | | Mean | Allocated | |---|---|---| | Struct id `ToString()` | 15.52 ns | 104 B | | Record id `ToString()` | 19.18 ns | **104 B** | | Struct id `Parse` | 23.62 ns | 96 B | | Record id `Parse` | 28.11 ns | 152 B | **This one goes against the recommendation, and it is a real finding.** The struct identifier's `ToString` allocates 2.2 times what the record identifier's does. It is not the struct's fault, it is the generator's. The struct form emits: ```csharp public override string ToString() => IdPrefix.Length == 0 ? ValueToString() : IdPrefix + "_" + ValueToString(); private string ValueToString() => Convert.ToString(Value, CultureInfo.InvariantCulture) ?? string.Empty; ``` `Convert.ToString(object, IFormatProvider)` boxed the `Guid` and built the 36-character string, and the concatenation then built a second 40-character string: three allocations against the record form's one, which went through an interpolated string. A struct identifier written into a log line on every request paid 232 bytes for it. The generator now writes the interpolated form too, pinned to the invariant culture: ```csharp public override string ToString() => IdPrefix.Length == 0 ? string.Create(CultureInfo.InvariantCulture, $"{Value}") : string.Create(CultureInfo.InvariantCulture, $"{IdPrefix}_{Value}"); ``` Nothing is boxed, one string comes out, and both forms now allocate 104 bytes. The table above is from after that change. ## The Entity Framework round trip The question is whether the form of the identifier matters to persistence at all. [Identifiers](https://dylansnel.github.io/DDDToolkit/docs/identifiers.md#struct-or-record) says it does not, on the strength of this measurement. Both aggregates are the same shape, with the same payload column, keyed differently: ```csharp [AggregateRoot] public partial class StructOrder { ... } [AggregateRoot] public partial class RecordOrder { ... } ``` ### Inserting 1,000 aggregates added and saved, against a database created fresh for each measured invocation. | | Mean | Allocated | |---|---|---| | Struct id | 30.26 ms ± 3.68 | 7.38 MB | | Record id | 31.44 ms ± 4.15 | 7.71 MB | The times overlap. Do not read a winner into them: the error bars are ±12%, the distribution is bimodal, and a database write is not a thing you measure to three significant figures. The allocation figure is deterministic and says what there is to say: 4% more, which is the 1,000 identifier objects. ### Reading 2,000 rows in the table. "Read all" materialises every row through a new context; "find by key" does 100 single-row lookups. | | Mean | Allocated | |---|---|---| | Read all, struct id | 1.52 ms | 1090.03 KB | | Read all, record id | 1.39 ms | 1293.20 KB | | Find by key, struct id | 1.11 ms | 888.16 KB | | Find by key, record id | 1.20 ms | 899.90 KB | The claim holds, with one correction to how it is phrased. **The struct identifier costs nothing in persistence, and neither does the record identifier.** Both go through the same generated value converter to the same provider column (the provider test suite asserts that the two produce the same column type), and the database work swamps everything either of them does. The 19% extra allocation on the record read is one object per row, and it disappears into the 1 MB that materialising 2,000 rows costs anyway. If you were choosing between the two forms on persistence alone, there would be nothing to choose. ## What this means for the recommendation [Identifiers](https://dylansnel.github.io/DDDToolkit/docs/identifiers.md) says to prefer the struct form. These are the reasons that recommendation has rested on, and what the measurements say about each: | Claim | Verdict | |---|---| | The struct is 16 bytes and the record adds a reference and a header | True, and more than it sounds: 64 bytes against 16, because the record also carries validation state. | | A list of ten thousand is one block instead of ten thousand objects | True. Reading through the reference costs about 20%. | | The struct costs nothing in persistence | True. So does the record: this is not a reason to choose either. | | (unstated) Equality and hashing | Was the strongest reason by far, at 17x to 70x with allocation on every comparison. Writing this page found the cause and it is fixed; the gap is now 1.4x to 2.5x with nothing allocated. | | (unstated) `ToString` | Was the one place the record form won, because of a fixable inefficiency in the generated struct. Also fixed; both allocate 104 bytes. | The short version: choose the struct form to avoid an object per identifier and a dereference on every read, not for the database, and no longer because equality is catastrophic. Two of the five rows in this table describe defects that existed only because nobody had measured, which is the case for keeping the benchmarks in the repository rather than running them once. ## Provider coverage The benchmarks above are SQLite only. Correctness on other providers is a separate question, and it is answered by a separate suite: `Tests/DDDToolkit.EntityFramework.Providers.Tests` runs the mapping, concurrency, outbox and inbox tests against real PostgreSQL 17 and SQL Server 2022 in [Testcontainers](https://dotnet.testcontainers.org). Without Docker every test in it skips with a message naming the reason, so a machine without Docker stays green and stays honest about it. Three things that suite pins down and SQLite never could: - **The `ddd` schema is real.** SQLite has no schemas and silently drops the argument, so the default the outbox and inbox ship with was never exercised. It works: `EnsureCreated` creates the schema and both tables land in it on both servers. - **A struct id and a record id produce the same column.** `uuid` on PostgreSQL, `uniqueidentifier` on SQL Server, for the key, for a plain property and for a nullable one. This is what the persistence numbers above are measuring the other half of. - **The outbox timestamp conversion costs SQL Server and not PostgreSQL.** The outbox stores every timestamp as a UTC `DateTime` because SQLite cannot order by `DateTimeOffset`. On PostgreSQL both land in `timestamp with time zone`, so the workaround is free. On SQL Server the column is `datetime2` where a `DateTimeOffset` would have been `datetimeoffset`: the offset is not stored, which is harmless while everything is UTC and is still a column shape SQL Server users did not choose. A fourth is a difference rather than a cost: a primitive collection of converted ids becomes a native `integer[]` on PostgreSQL and a JSON `nvarchar` on SQL Server. Both round trip, and a query that reaches inside one will not port. ## Things this page does not measure - **Other providers.** Everything here is SQLite in memory. Mapping behaviour on PostgreSQL and SQL Server is covered by the suite above, but nothing times it. - **The generators.** Build-time cost of the source generators is not measured anywhere. - **The outbox.** Throughput of `OutboxProcessor` under load, and what the `ProcessedAt` index does as the table grows, are open questions. - **JSON and GraphQL.** The generated converters are not benchmarked. - **Value objects.** `ValueObject` equality has the same `GetEqualityComponents` cost measured above, and value objects are compared far less often than identifiers, but nobody has checked. # Migrating to 3.0 This page is for somebody on 2.0.22 who wants to upgrade. Every break is listed with the code you have now and the code you need. The [changelog](https://github.com/DylanSnel/DDDToolkit/blob/main/CHANGELOG.md) has the full list of changes; this page only covers the ones that make your solution stop compiling or stop behaving the same. Budget an afternoon for a medium sized solution. Most of the work is mechanical: the compiler finds the event changes for you. The one thing it cannot find for you is the change to when handlers run, in [section 6](https://dylansnel.github.io/DDDToolkit/docs/migrating-to-3.md#6-events-are-dispatched-before-the-save-on-both-paths). ## What actually breaks | Break | Section | |---|---| | `net8.0` is no longer supported | [1](https://dylansnel.github.io/DDDToolkit/docs/migrating-to-3.md#1-retarget-to-net-10) | | Child entities no longer have domain events | [2](https://dylansnel.github.io/DDDToolkit/docs/migrating-to-3.md#2-domain-events-moved-to-the-aggregate-root) | | `AddDomainEvent` is gone | [3](https://dylansnel.github.io/DDDToolkit/docs/migrating-to-3.md#3-adddomainevent-becomes-raisedomainevent) | | The public `ClearDomainEvents` is gone | [4](https://dylansnel.github.io/DDDToolkit/docs/migrating-to-3.md#4-draining-goes-through-ihasdomainevents) | | `IDomainEvent` has members now | [5](https://dylansnel.github.io/DDDToolkit/docs/migrating-to-3.md#5-idomainevent-carries-an-id-and-a-timestamp) | | Handlers run before the save on both paths | [6](https://dylansnel.github.io/DDDToolkit/docs/migrating-to-3.md#6-events-are-dispatched-before-the-save-on-both-paths) | | `UseDomainEvents(Func<...>)` is obsolete | [7](https://dylansnel.github.io/DDDToolkit/docs/migrating-to-3.md#7-the-entity-framework-registration-changed) | | Aggregate roots gain a `Version` column | [8](https://dylansnel.github.io/DDDToolkit/docs/migrating-to-3.md#8-aggregate-roots-gain-a-version-column) | | Hand-written identifiers need `IEquatable` | [9](https://dylansnel.github.io/DDDToolkit/docs/migrating-to-3.md#9-hand-written-identifiers-need-iequatable) | | Misapplied attributes now fail the build | [10](https://dylansnel.github.io/DDDToolkit/docs/migrating-to-3.md#10-misapplied-attributes-now-fail-the-build) | | `CheckInvariants`, `EnsureInvariants`, `GetInvariantViolations` and their `Own` pair are now generated member names | [11](https://dylansnel.github.io/DDDToolkit/docs/migrating-to-3.md#11-entities-gain-an-invariant-seam) | | A save now runs your aggregates' invariants | [11](https://dylansnel.github.io/DDDToolkit/docs/migrating-to-3.md#11-entities-gain-an-invariant-seam) | | The packages have new ids for now, `Temp.DDDToolkit.*` | [12](https://dylansnel.github.io/DDDToolkit/docs/migrating-to-3.md#the-package-ids) | | HotChocolate 16, Entity Framework 10, FluentValidation 12 | [12](https://dylansnel.github.io/DDDToolkit/docs/migrating-to-3.md#12-package-versions) | | MediatR is no longer referenced | [13](https://dylansnel.github.io/DDDToolkit/docs/migrating-to-3.md#13-mediatr-is-replaced-by-mediator-in-the-examples) | Then there are three changes you do not have to make, but should: [struct identifiers](https://dylansnel.github.io/DDDToolkit/docs/migrating-to-3.md#14-optional-make-your-identifiers-structs), [generated collections](https://dylansnel.github.io/DDDToolkit/docs/migrating-to-3.md#15-optional-let-the-generator-write-your-collections) and [module boundaries](https://dylansnel.github.io/DDDToolkit/docs/migrating-to-3.md#16-optional-declare-your-modules). ## 1. Retarget to .NET 10 The runtime libraries target `net10.0`. There is no `net8.0` build, so the whole solution moves. ```xml net8.0 ``` ```xml net10.0 ``` Pin the SDK so everyone builds with the same one: ```json { "sdk": { "version": "10.0.100", "rollForward": "latestFeature" } } ``` The generators still target `netstandard2.0` and reference nothing at run time, so they load in any recent SDK. In 2.x they referenced other assemblies, which stopped resolving under newer SDKs and surfaced as `CS8784`. If that is the error that brought you here, this release is the fix. ## 2. Domain events moved to the aggregate root In 2.x every `Entity` had a domain event list. In 3.0 only `AggregateRoot` has one, because only the root is a consistency boundary. If a child entity raised events, the root has to raise them instead. Give the child a method that reports what happened and let the root turn that into an event: ```csharp [Entity] public partial class OrderLine { public void ChangeQuantity(int quantity) { Quantity = quantity; AddDomainEvent(new LineQuantityChanged(Id, quantity)); // 2.x } } ``` ```csharp [Entity] public partial class OrderLine { internal void ChangeQuantity(int quantity) => Quantity = quantity; } [AggregateRoot] public partial class Order { public void ChangeLineQuantity(OrderLineId lineId, int quantity) { var line = _lines.Single(l => l.Id == lineId); line.ChangeQuantity(quantity); RaiseDomainEvent(new LineQuantityChanged(Id, lineId, quantity)); } } ``` This is worth doing even where the compiler does not force it. In 2.x the Entity Framework interceptor collected events from every tracked entity, so a child could publish something the root did not know about, and the root is the thing that decides whether the change was legal. Reading events off a child also stops compiling: ```csharp var events = orderLine.DomainEvents; // 2.x var events = order.DomainEvents; // 3.0, the root owns them ``` ## 3. `AddDomainEvent` becomes `RaiseDomainEvent` `AddDomainEvent` was public, so anything holding a reference to an order could put an event into it. `RaiseDomainEvent` is `protected`. ```csharp public void Cancel(CancellationReason reason) { Status = OrderStatus.Cancelled; AddDomainEvent(new OrderCancelled(Id, reason)); // 2.x } ``` ```csharp public void Cancel(CancellationReason reason) { Status = OrderStatus.Cancelled; RaiseDomainEvent(new OrderCancelled(Id, reason)); // 3.0 } ``` Inside the aggregate this is a rename. Outside it, it is a redesign. Code like this: ```csharp order.AddDomainEvent(new OrderExported(order.Id)); // 2.x, from an application service ``` has no direct replacement, and that is deliberate. Either the aggregate owns the fact, in which case give it a method: ```csharp order.MarkExported(); // which raises the event itself ``` or the fact is not about the order at all, in which case it is an application concern and belongs in whatever you use to publish application messages, not on the aggregate. ## 4. Draining goes through `IHasDomainEvents` `ClearDomainEvents()` was a public method on every entity. It is now an explicit interface implementation, along with a new `DequeueDomainEvents()`: ```csharp public interface IHasDomainEvents { IReadOnlyList DomainEvents { get; } IReadOnlyList DequeueDomainEvents(); // returns and empties void ClearDomainEvents(); // discards } ``` ```csharp order.ClearDomainEvents(); // 2.x ((IHasDomainEvents)order).ClearDomainEvents(); // 3.0 var events = ((IHasDomainEvents)order).DequeueDomainEvents(); // 3.0, read and empty in one step ``` The cast is the point. Application code that can call `ClearDomainEvents()` by accident can write a row whose event never happened. You will rarely write either line: the Entity Framework integration drains the aggregates during save. If you wrote your own dispatcher in 2.x, it looked something like this: ```csharp var entities = context.ChangeTracker.Entries() .Select(e => e.Entity).Where(e => e.DomainEvents.Any()).ToList(); var events = entities.SelectMany(e => e.DomainEvents).ToList(); entities.ForEach(e => e.ClearDomainEvents()); ``` ```csharp var events = context.ChangeTracker.Entries() .SelectMany(entry => entry.Entity.DequeueDomainEvents()) .ToList(); ``` Better still, delete it and use `PublishDomainEventsInterceptor`. See [Entity Framework](https://dylansnel.github.io/DDDToolkit/docs/event-delivery.md). ## 5. `IDomainEvent` carries an id and a timestamp In 2.x `IDomainEvent` was empty, so every consumer had to reconstruct identity and timing from context. It now has two members: ```csharp public interface IDomainEvent { Guid EventId { get; } DateTimeOffset OccurredAt { get; } } ``` Every event type you have stops compiling until it supplies them. The one line fix is to derive from the `DomainEvent` record base, which supplies both: ```csharp public record OrderPlaced(OrderId OrderId) : IDomainEvent; // 2.x ``` ```csharp using DDDToolkit.BaseTypes; [DomainEventName("ordering.order-placed")] public sealed record OrderPlaced(OrderId OrderId) : DomainEvent; // 3.0 ``` `EventId` is a version 7 `Guid`, so it is unique and sorts by time. `OccurredAt` is the current UTC time. Both are `init`, so a replay can supply recorded values. If you have a marker interface of your own, put `DomainEvent` in front of it: ```csharp public interface IBaseDomainEvent : IDomainEvent, INotification; public sealed record OrderPlaced(OrderId OrderId) : DomainEvent, IBaseDomainEvent; ``` You can still implement `IDomainEvent` directly. You then write the two properties yourself. `[DomainEventName]` is new and optional. Without it, an event is named by convention: its module and its class name in kebab case, `ordering.order-placed`. Add the attribute when you rename a class whose name is already stored or published, to keep the old name; see [Stable names](https://dylansnel.github.io/DDDToolkit/docs/domain-events.md#stable-names). The outbox uses the name as the message name, and `DomainEventName.Of` resolves it. (Early 3.0 builds used the bare class name instead; rows they wrote are still read.) ### Making the timestamp deterministic in tests Because the aggregate constructs its own events, your test cannot pass an initialiser. Use `DomainEventClock` instead: ```csharp using var scope = DomainEventClock.Use(fakeClock); var order = new Order(orderId, customerId); order.DomainEvents.Single().OccurredAt.Should().Be(fakeClock.GetUtcNow()); ``` See [Deterministic time in tests](https://dylansnel.github.io/DDDToolkit/docs/testing.md#deterministic-time-in-tests). ## 6. Events are dispatched before the save, on both paths This is the change the compiler cannot find for you, so read it even if everything builds. In 2.x the two save paths behaved differently. `SaveChanges` dispatched from `SavingChanges`, before the database write. `SaveChangesAsync` dispatched from `SavedChangesAsync`, after it. So a handler saw uncommitted data or committed data depending only on which overload the caller happened to use. In 3.0 both paths dispatch before the write. Handlers always see the same thing. What that means for your handlers: - A handler that changes tracked entities on the same `DbContext` now has those changes saved by the same `SaveChanges` call, inside the same transaction. In 2.x, on the async path, they needed a second save. - A handler that throws now aborts the save. In 2.x, on the async path, the row was already written. - A handler that assumed the row was committed, for example one that reads it back through a second context or hands the id to a background job, is now wrong. The row is not there yet. That last case is the one to go looking for. The fix is the outbox: the event is written in the same transaction as the aggregate and delivered after the commit, at least once. ```csharp builder.Services.AddDDDToolkitEntityFramework(options => { options.DispatchInProcess(/* your delegate */); options.UseOutbox(outbox => outbox.RegisterEventsFromAssemblyContaining()); }); builder.Services.AddOutboxBackgroundService(TimeSpan.FromSeconds(2)); ``` ```csharp protected override void OnModelCreating(ModelBuilder modelBuilder) => modelBuilder.AddDomainEventOutbox(Database); ``` Outbox handlers must be idempotent, keyed on `EventId`. See [the outbox](https://dylansnel.github.io/DDDToolkit/docs/event-delivery.md#the-outbox-in-detail). One more thing that is new rather than changed: if an aggregate has pending events and no delivery mode is configured, `SaveChanges` throws instead of silently dropping them. ## 7. The Entity Framework registration changed ```csharp builder.Services.UseDomainEvents(async (sp, events) => // 2.x { var mediator = sp.GetRequiredService(); foreach (var e in events) await mediator.Publish(e); }); builder.Services.AddDbContext((sp, options) => { options.UseSqlServer(connectionString); options.AddDomainEventInterceptor(sp); }); ``` ```csharp builder.Services.AddDDDToolkitEntityFramework(options => // 3.0 options.DispatchInProcess(async (sp, events, cancellationToken) => { var publisher = sp.GetRequiredService(); foreach (var e in events) await publisher.Publish(e, cancellationToken); })); builder.Services.AddDbContext((sp, options) => options .UseSqlServer(connectionString) .UseDDDToolkit(sp)); ``` Three differences. The delegate takes a `CancellationToken` and an `IReadOnlyList` instead of a `List`. `UseDDDToolkit` adds the concurrency interceptor as well as the event one. And the options object is where the outbox, `MaxDispatchRounds` and `TimeProvider` live. The old overload still works and still compiles: ```csharp [Obsolete] UseDomainEvents(Func, Task> interceptorAction) ``` It forwards to `DispatchInProcess`, so it dispatches before the save now, like everything else. Treat the warning as a reminder, not an emergency. `AddDomainEventInterceptor` is kept too, as an alias of `UseDDDToolkit`. ### One new call: `AddDDDToolkitConventions` There is a third call that has no 2.x equivalent: ```csharp protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) { configurationBuilder.AddDDDToolkitConventions(); // new in 3.0 configurationBuilder.AddOrderingConverters(); // you already had this } ``` It maps `Version` as a concurrency token, maps generated read-only collections of primitives, and ignores `[Internal]` members. Without it, a generated collection property is silently skipped, because Entity Framework only discovers primitive properties that have a setter. ## 8. Aggregate roots gain a `Version` column `AggregateRoot` now has `public long Version { get; private set; }`, and `AddDDDToolkitConventions` maps it as a concurrency token. Every aggregate root table needs a new column, so scaffold a migration before you deploy: ```bash dotnet ef migrations add AggregateVersion ``` Existing rows get `0`, which is correct: the version only has to agree with itself from the next save onwards. From then on, a save against a stale aggregate throws `ConcurrencyConflictException` instead of succeeding silently: ```csharp try { await context.SaveChangesAsync(cancellationToken); } catch (ConcurrencyConflictException conflict) { // reload, reapply, retry, or report the conflict to the user } ``` If some aggregate genuinely must not be version checked, configure the property yourself in `OnModelCreating`; explicit configuration wins over the convention. ## 9. Hand-written identifiers need `IEquatable` `Entity` used to be constrained to `IEntityId`. It is now constrained to `IEntityId, IEquatable`, which is what lets equality avoid boxing. Identifiers generated by `[EntityId]` satisfy this already, because records and record structs implement `IEquatable` for free. Only a hand-written identifier is affected: ```csharp public sealed class LegacyId : IEntityId { } // 2.x public sealed class LegacyId : IEntityId, IEquatable { } // 3.0 ``` Equality on `Entity` is also null-safe now. `GetHashCode` on an entity whose id is null returns 0 instead of throwing, and `==` handles null on both sides. ## 10. Misapplied attributes now fail the build 2.x reported five diagnostics: DDD00001, DDD00002, DDD00010, DDD00011 and DDD00013. Everything else it got wrong generated nothing and said nothing, or was rejected by the compiler with `CS0592`, which named no cause. The attributes now accept both classes and structs, so the generator reports its own diagnostic instead of the compiler refusing the attribute. Nine diagnostics are new to a first build, and these are the ones you can expect: | You wrote | 3.0 says | |---|---| | `[EntityId]` on a plain class or struct | [DDD00003](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00003) | | `[EntityId]` on a non readonly record struct | [DDD00004](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00004), a warning | | Any of them on a non partial type | [DDD00005](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00005) | | Any of them on a generic type | [DDD00006](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00006) | | The generated identifier's name is already taken | [DDD00007](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00007) | | A type argument that is neither an identifier nor something one can wrap | [DDD00008](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00008) | | `[Entity]` and `[AggregateRoot]` on the same class | [DDD00009](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00009) | | A setter on a generated collection property | [DDD00020](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00020) | | A field or property typed as another aggregate root | [DDD00021](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00021), a warning | DDD00001, DDD00002, DDD00010, DDD00011 and DDD00013 also fire in more cases than they used to, now that a struct or a record struct can carry the attribute at all. DDD00021 is the one most likely to be noisy on a 2.x model, because 2.x said nothing about an `Order.Customer` navigation and this release says it widens the aggregate boundary. It is a warning, everything is still generated, and [Reference other aggregates by id](https://dylansnel.github.io/DDDToolkit/docs/entities-and-aggregates.md#reference-other-aggregates-by-id) has the argument. If you are not ready to have it now, turn it off for the project and come back to it: ```xml $(NoWarn);DDD00021 ``` Two more exist and neither can fire on an upgraded 2.x solution: [DDD00022](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00022) and [DDD00023](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00023) are silent until two assemblies declare themselves [modules](https://dylansnel.github.io/DDDToolkit/docs/modules.md). See [section 16](https://dylansnel.github.io/DDDToolkit/docs/migrating-to-3.md#16-optional-declare-your-modules). They are all real: in 2.x those types were producing nothing, and you were living with whatever the missing code did not do. DDD00006 in particular used to produce a second, unrelated, non-generic type that compiled on its own, which is why the errors you saw talked about members that "do not exist". `[DontCompare]` and `[Internal]` are unchanged, and neither has ever reported anything. `[DomainEventName]` is new in 3.0 and optional. The full list with a fix for each is in [Diagnostics](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md). ## 11. Entities gain an invariant seam Every `[Entity]` and `[AggregateRoot]` now gets five generated members: ```csharp partial void CheckInvariants(); public override void EnsureInvariants(); public override IReadOnlyList GetInvariantViolations(); public override void EnsureOwnInvariants(); public override IReadOnlyList GetOwnInvariantViolations(); ``` The first pair of the four answers for the whole aggregate, this object's child entities included; the `Own` pair answers for this object alone and exists for a caller that is already walking the graph, which in practice means the save. See [Invariants](https://dylansnel.github.io/DDDToolkit/docs/invariants.md#what-one-question-covers). Two things follow, one mechanical and one behavioural. **The names are taken.** A 2.x class that already declares a member called `CheckInvariants`, `EnsureInvariants`, `GetInvariantViolations`, `EnsureOwnInvariants` or `GetOwnInvariantViolations` collides with the generated one. Rename yours; the compiler points at the line. This is rare, but it is the only way this feature can stop a build. **A save now runs them.** `UseDDDToolkit` registers an `InvariantInterceptor` that checks every entity a `SaveChanges` adds or modifies, child entities included, and the aggregate root of every changed child. Until you state a rule those calls do nothing at all: the compiler erases an unimplemented `partial void` and every call to it, so an unchanged 2.x aggregate behaves exactly as before and costs nothing. You only notice the interceptor once you write a rule. There is nothing to switch on and nothing to migrate. When you are ready to use it: ```csharp [AggregateRoot] public partial class Order { partial void CheckInvariants() { if (Status != OrderStatus.Draft && Lines.Count == 0) { throw InvariantViolation("A placed order must have at least one line."); } } } ``` A rule that the data in your database already breaks will start failing saves of those rows. The interceptor only checks aggregates the save touches, so nothing breaks on load, but it is worth running the rule over production data as a query before you deploy it. See [Invariants](https://dylansnel.github.io/DDDToolkit/docs/invariants.md). ## 12. Package versions ### The package ids 3.x is published as `Temp.DDDToolkit`, `Temp.DDDToolkit.EntityFramework` and so on, for now. The nuget.org account that owns the `DDDToolkit.*` ids cannot publish at the moment, so updating `DDDToolkit` to the latest version still gets you 2.0.22. Swap each reference for the prefixed id instead: ```xml ``` Only the ids changed. The assemblies and namespaces are still `DDDToolkit.*`, so no `using` has to move, and the analyzer packages still arrive on their own as dependencies. Keep no 2.0.22 reference beside a 3.0.0 one: they are different packages to NuGet, so it will not pick one of the two for you, and both would put an assembly named `DDDToolkit` in the build. ### The dependencies | Package | 2.0.22 | 3.0.0 | |---|---|---| | Microsoft.EntityFrameworkCore | 8.0.8 | 10.0.12 | | FluentValidation | 11.9.2 | 12.1.1 | | HotChocolate.AspNetCore | 14.0.0-rc.1 | 16.6.6 | | HotChocolate.Execution | 14.0.0-rc.1 | replaced by `HotChocolate` 16.6.6 | Two things to know about the HotChocolate jump. `HotChocolate.Execution` has no stable 16 release. The execution engine ships in the `HotChocolate` package now, so change the reference rather than looking for a newer version of the old one. `GraphQLTypeAttribute` is constrained on `ITypeDefinition`, which replaced `INamedType`. If you named a custom scalar type in that attribute, the constraint is the only thing that changed. If you move your own HotChocolate code onto 16.6.6 alongside the toolkit, one more rename is likely to catch you: the type-system configuration classes. `DefinitionBase` and `ObjectTypeDefinition` do not exist in the 16.6.6 assemblies. The equivalents are `TypeSystemConfiguration`, `ObjectTypeConfiguration`, `InterfaceTypeConfiguration`, `InputObjectTypeConfiguration` and `FieldConfiguration`, all in `HotChocolate.Types.Descriptors.Configurations`, so a `TypeInterceptor` override now takes a `TypeSystemConfiguration`. What did not change is the part the toolkit relies on most: | Still the same in 16.6.6 | Where | |---|---| | `BindRuntimeType()` | `Microsoft.Extensions.DependencyInjection` | | `AddTypeConverter()` | `Microsoft.Extensions.DependencyInjection` | | `IChangeTypeProvider` and the `ChangeType` delegate | `HotChocolate.Utilities` | | `[InterfaceType]` with a `static partial void Configure` | `HotChocolate.Types.Analyzers` | There is a schema change too. `[Internal]` members are now removed during type discovery rather than flagged at completion, so the types they referenced stop appearing in the schema. If your 2.x schema contained FluentValidation's `ValidationFailure` or the always-valid twins, they are gone. That is the bug being fixed, but check your persisted queries before you deploy. See [GraphQL](https://dylansnel.github.io/DDDToolkit/docs/graphql.md). ## 13. MediatR is replaced by Mediator in the examples MediatR is commercially licensed from version 13, which is why the repository stayed on a 12.x version. The examples now publish through [Mediator](https://github.com/martinothamar/Mediator), which is MIT and source generated, and there is a `DDDToolkit.Mediator` package that writes the dispatch delegate for you: ```csharp builder.Services.AddMediator(options => options.ServiceLifetime = ServiceLifetime.Scoped); builder.Services.AddDDDToolkitEntityFramework(options => options.DispatchWithMediator()); ``` **You do not have to move.** The toolkit core has never had a mediator dependency and still does not: in-process delivery is a delegate, and both packages fit it. If you are staying on MediatR, keep your delegate and change only its signature: ```csharp options.DispatchInProcess(async (sp, events, cancellationToken) => { var mediator = sp.GetRequiredService(); foreach (var e in events) await mediator.Publish(e, cancellationToken); }); ``` If you do move, the differences that bite are that Mediator is source generated, so `AddMediator` has to be called in the assembly that carries `Mediator.SourceGenerator`, and that its default service lifetime is singleton. A handler that injects your `DbContext` needs `ServiceLifetime.Scoped`, and the generator reads that from the `AddMediator` call at compile time, so it has to be written there. An event that does not implement Mediator's `INotification` makes `DispatchWithMediator()` throw naming the event type. It is not skipped: the interceptor has already dequeued the event by then, so skipping would destroy it with no row, no log and nothing to retry. ## 14. Optional: make your identifiers structs `[EntityId]` now accepts a `readonly partial record struct`, and that is the recommended shape. The identifier costs no allocation and gets a fuller surface than the record form: ```csharp [EntityId("ORD")] public partial record OrderId; // 2.x, still works ``` ```csharp [EntityId("ORD")] public readonly partial record struct OrderId; // 3.0, recommended ``` A struct identifier gets `Value`, `Empty`/`IsEmpty`, `CreateUnique`/`CreateSequential`, `ToString()` with the prefix, `Parse`/`TryParse`, `IParsable`, `IComparable`, explicit conversions and a JSON converter. The column does not change. Both forms store the wrapped value, so a `Guid` id is a `Guid` column either way and no migration is needed. Two things do change. A struct has no `null`, so a property that used `null` to mean "not set" uses `OrderId?` or `IsEmpty` instead. And a struct identifier has no always-valid twin, because there is no invalid state to exclude. While you are there, you can often delete the declaration entirely. An identifier used only by its own aggregate can be generated from the aggregate: ```csharp [AggregateRoot("ORD")] public partial class Order { } // also generates OrderId ``` Keep the explicit declaration for an identifier that other aggregates, DTOs or API contracts refer to. See [Identifiers](https://dylansnel.github.io/DDDToolkit/docs/identifiers.md). ## 15. Optional: let the generator write your collections A get-only `partial` collection property gets a backing field, a read-only view and Entity Framework's `[BackingField]`: ```csharp private readonly List _lines = new(); // 2.x public IReadOnlyList Lines => _lines.AsReadOnly(); ``` ```csharp public partial IReadOnlyList Lines { get; } // 3.0 ``` `_lines` still exists, written by the generator, so the rest of the aggregate does not change. This is worth doing mostly where 2.x code exposed the list itself: ```csharp public List Orders { get; private set; } = new(); // any caller can Add public partial IReadOnlyList Orders { get; } // only the aggregate can ``` `IReadOnlyList`, `IReadOnlyCollection`, `IEnumerable` and `IReadOnlySet` are supported. A setter is an error ([DDD00020](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00020)). See [Entities and aggregates](https://dylansnel.github.io/DDDToolkit/docs/entities-and-aggregates.md#read-only-collections). ## 16. Optional: declare your modules This one has no 2.x equivalent at all, so there is nothing to migrate and nothing that breaks until you ask for it. If your solution is a modular monolith, it is the most valuable thing in this release that the compiler will not hand you. Mark an assembly as a module and say what it publishes: ```csharp [assembly: Module("Ordering")] ``` ```csharp [ModuleContract] [EntityId("CUS")] public readonly partial record struct CustomerId; ``` An analyzer then reports where one module names another module's unpublished type ([DDD00022](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00022)) or holds another module's entity as stored state ([DDD00023](https://dylansnel.github.io/DDDToolkit/docs/diagnostics.md#ddd00023)). Both are warnings and both are silent unless *both* assemblies carry `[assembly: Module]`, so adding the attribute to one project changes nothing, and adding it to a second gives you a list rather than a build break. Do not confuse it with `DDD_Module`, which is unchanged and unrelated: that MSBuild property names the generated `Add{Module}Converters` method and describes no boundary to anybody. Adopt one project at a time; [Modules](https://dylansnel.github.io/DDDToolkit/docs/modules.md#adopting-this-on-an-existing-codebase) has the order to do it in. ## From an earlier 3.0 build Skip this if you are coming from 2.0.22. Three things changed while 3.0 was being built, after some databases had already been created with it. ### Outbox and inbox timestamps **On PostgreSQL, nothing happens.** Npgsql maps both a UTC `DateTime` and a `DateTimeOffset` to `timestamptz`. The column is the same column and the bytes in it are the same bytes. There is no migration to write. **On SQL Server the column type changes**, from `datetime2` to `datetimeoffset`. Scaffolding a migration after upgrading produces an `ALTER COLUMN` for each timestamp column of the outbox and the inbox. The stored instant is preserved: SQL Server reads an existing `datetime2` as the same time at `+00:00`, which is correct because every value the outbox ever wrote was already UTC. So the meaning of a row does not change, but the table does, and you have to run the migration. If you would rather not, keep the old shape explicitly: ```csharp modelBuilder.AddDomainEventOutbox(Database, timestamps: DomainEventTimestamps.UtcDateTime); modelBuilder.AddDomainEventInbox(Database, timestamps: DomainEventTimestamps.UtcDateTime); ``` That produces exactly the columns you have, on every provider, and no migration at all. If you upgrade the code on SQL Server and do neither of those things, **reading the outbox throws**. The insert still works, because SQL Server converts the parameter into the column it has, and the instant it stores is correct. The read does not: `datetime2` comes back from the driver as a `DateTime`, the model wants a `DateTimeOffset`, and you get an `InvalidCastException` on the first row. That is the good outcome, and it is why this is safe to ship. An unmigrated database says so the first time the processor polls, rather than quietly disagreeing with the model. Both halves are asserted against a real SQL Server in `Tests/DDDToolkit.EntityFramework.Providers.Tests/Providers/ProviderMappingTests.cs`. Rows written before you notice are fine. The value that reached the column was already the right instant, so running the migration afterwards needs no data repair. **On SQLite, nothing happens**, because SQLite never had a choice. See [Timestamps](https://dylansnel.github.io/DDDToolkit/docs/event-delivery.md#timestamps) for what each provider gets now. ### The outbox `Version` column An outbox table that predates the `Version` column needs it added as a non-nullable `int` with a default of 1, which is what every existing row was written as. A column added without a default reads as 0, and the processor treats that as 1 for the same reason, so an upgrade that forgets the default still works. See [Versioning and upcasting](https://dylansnel.github.io/DDDToolkit/docs/integration-events.md#versioning-and-upcasting). ### The outbox `NextAttemptAt` column An outbox table that predates `NextAttemptAt` needs it added as a nullable column of the same type as `ProcessedAt`, without an index. A scaffolded migration writes exactly that. Existing rows read `null`, which means due now, so no row has to change. See [Failures, retries and poison messages](https://dylansnel.github.io/DDDToolkit/docs/event-delivery.md#failures-retries-and-poison-messages). ## What did not change - `[ValueObject]` and `[SingleValueObject]` keep their shape, their `Valid` twin, `ToValid()` and `InvalidValueObjectException`. - `[DontCompare]` and `[Internal]` mean what they always meant. - The `ColumnLength` argument of `[EntityId]` still becomes `HaveMaxLength`. - The generated `Add{Module}Converters` keeps its name and its place, and `DDD_Module` still names it. - `Entity.Id` still has a `protected set`, so a 2.x constructor that wrote `Id = id` after `base()` still compiles. `base(id)` is the better form. - Validation still runs through `protected bool Validate()`, or a generated body when `DDDToolkit.FluentValidation` is referenced. There is a second overload now, `protected override void Validate(ValidationErrorBuilder errors)`, and a non-throwing `TryToValid` beside `ToValid()`, but nothing you already wrote has to move. See [Failure handling](https://dylansnel.github.io/DDDToolkit/docs/value-objects.md#failure-handling). - Everything about integration events, sinks, the inbox, versioning and modules is new surface. None of it is on unless you call for it, and none of it replaces anything 2.x had. One behaviour inside value objects did change, quietly and for the better. In 2.x, `record with` on a value object copied the cached validity verdict, so a copy reported its source's verdict even when the property that changed was the one that had been validated. A copy now revalidates on demand. If you had a workaround for that, remove it. ## A checklist 1. Retarget every project to `net10.0` and pin the SDK. 2. Update the package references: `DDDToolkit.*` to `Temp.DDDToolkit.*` 3.0.0, and `HotChocolate.Execution` to `HotChocolate`. 3. Build. Fix the `IDomainEvent` errors by deriving your events from `DomainEvent`. 4. Fix the `AddDomainEvent` errors by renaming to `RaiseDomainEvent` inside aggregates, and by giving the aggregate a method where the call was outside one. 5. Move any events raised by child entities up to their root. 6. Fix the `ClearDomainEvents` errors with a cast to `IHasDomainEvents`, or delete the code and use the interceptor. 7. Fix whatever diagnostics the generators report. Read the message; each one names the type. DDD00021 is a warning about aggregate references and can wait behind a `NoWarn` if the list is long. 8. Rename any member of your own called `CheckInvariants`, `EnsureInvariants`, `GetInvariantViolations`, `EnsureOwnInvariants` or `GetOwnInvariantViolations`; all five names are now generated onto every entity. 9. Replace `UseDomainEvents(...)` and `AddDomainEventInterceptor(...)` with `AddDDDToolkitEntityFramework(...)` and `UseDDDToolkit(...)`. 10. Add `AddDDDToolkitConventions()` to `ConfigureConventions`. 11. Scaffold a migration for the `Version` column. 12. Read section 6 and decide, per handler, whether it can run before the commit. Move the ones that cannot to the outbox. 13. Run your tests. Then, before you deploy, look at your GraphQL schema and your persisted queries. Nothing on that list is the new surface. Invariants, integration events, sinks, the inbox and modules are all opt-in, and none of them is worth turning on in the same change as the upgrade. Get green first.