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 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.
What actually breaks
| Break | Section |
|---|---|
net8.0 is no longer supported | 1 |
| Child entities no longer have domain events | 2 |
AddDomainEvent is gone | 3 |
The public ClearDomainEvents is gone | 4 |
IDomainEvent has members now | 5 |
| Handlers run before the save on both paths | 6 |
UseDomainEvents(Func<...>) is obsolete | 7 |
Aggregate roots gain a Version column | 8 |
Hand-written identifiers need IEquatable<T> | 9 |
| Misapplied attributes now fail the build | 10 |
CheckInvariants, EnsureInvariants, GetInvariantViolations and their Own pair are now generated member names | 11 |
| A save now runs your aggregates' invariants | 11 |
The packages have new ids for now, Temp.DDDToolkit.* | 12 |
| HotChocolate 16, Entity Framework 10, FluentValidation 12 | 12 |
| MediatR is no longer referenced | 13 |
Then there are three changes you do not have to make, but should: struct identifiers, generated collections and module boundaries.
1. Retarget to .NET 10
The runtime libraries target net10.0. There is no net8.0 build, so the whole solution moves.
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
Pin the SDK so everyone builds with the same one:
{
"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<TId> had a domain event list. In 3.0 only AggregateRoot<TId> 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:
[Entity<OrderLineId>]
public partial class OrderLine
{
public void ChangeQuantity(int quantity)
{
Quantity = quantity;
AddDomainEvent(new LineQuantityChanged(Id, quantity)); // 2.x
}
}
[Entity<OrderLineId>]
public partial class OrderLine
{
internal void ChangeQuantity(int quantity) => Quantity = quantity;
}
[AggregateRoot<OrderId>]
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:
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.
public void Cancel(CancellationReason reason)
{
Status = OrderStatus.Cancelled;
AddDomainEvent(new OrderCancelled(Id, reason)); // 2.x
}
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:
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:
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():
public interface IHasDomainEvents
{
IReadOnlyList<IDomainEvent> DomainEvents { get; }
IReadOnlyList<IDomainEvent> DequeueDomainEvents(); // returns and empties
void ClearDomainEvents(); // discards
}
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:
var entities = context.ChangeTracker.Entries<IHasDomainEvents>()
.Select(e => e.Entity).Where(e => e.DomainEvents.Any()).ToList();
var events = entities.SelectMany(e => e.DomainEvents).ToList();
entities.ForEach(e => e.ClearDomainEvents());
var events = context.ChangeTracker.Entries<IHasDomainEvents>()
.SelectMany(entry => entry.Entity.DequeueDomainEvents())
.ToList();
Better still, delete it and use PublishDomainEventsInterceptor. See
Entity Framework.
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:
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:
public record OrderPlaced(OrderId OrderId) : IDomainEvent; // 2.x
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:
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.
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:
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.
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
DbContextnow has those changes saved by the sameSaveChangescall, 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.
builder.Services.AddDDDToolkitEntityFramework(options =>
{
options.DispatchInProcess(/* your delegate */);
options.UseOutbox(outbox => outbox.RegisterEventsFromAssemblyContaining<Program>());
});
builder.Services.AddOutboxBackgroundService<OrderingContext>(TimeSpan.FromSeconds(2));
protected override void OnModelCreating(ModelBuilder modelBuilder) => modelBuilder.AddDomainEventOutbox(Database);
Outbox handlers must be idempotent, keyed on EventId. See
the outbox.
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
builder.Services.UseDomainEvents(async (sp, events) => // 2.x
{
var mediator = sp.GetRequiredService<IMediator>();
foreach (var e in events) await mediator.Publish(e);
});
builder.Services.AddDbContext<OrderingContext>((sp, options) =>
{
options.UseSqlServer(connectionString);
options.AddDomainEventInterceptor(sp);
});
builder.Services.AddDDDToolkitEntityFramework(options => // 3.0
options.DispatchInProcess(async (sp, events, cancellationToken) =>
{
var publisher = sp.GetRequiredService<IPublisher>();
foreach (var e in events) await publisher.Publish(e, cancellationToken);
}));
builder.Services.AddDbContext<OrderingContext>((sp, options) => options
.UseSqlServer(connectionString)
.UseDDDToolkit(sp));
Three differences. The delegate takes a CancellationToken and an IReadOnlyList<IDomainEvent>
instead of a List<IDomainEvent>. 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:
[Obsolete] UseDomainEvents(Func<IServiceProvider, List<IDomainEvent>, 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:
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<TId> 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:
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:
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<TId> used to be constrained to IEntityId. It is now constrained to
IEntityId, IEquatable<TId>, which is what lets equality avoid boxing.
Identifiers generated by [EntityId<T>] satisfy this already, because records and record structs
implement IEquatable<T> for free. Only a hand-written identifier is affected:
public sealed class LegacyId : IEntityId { } // 2.x
public sealed class LegacyId : IEntityId, IEquatable<LegacyId> { } // 3.0
Equality on Entity<TId> 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<T>] on a plain class or struct | DDD00003 |
[EntityId<T>] on a non readonly record struct | DDD00004, a warning |
| Any of them on a non partial type | DDD00005 |
| Any of them on a generic type | DDD00006 |
| The generated identifier's name is already taken | DDD00007 |
| A type argument that is neither an identifier nor something one can wrap | DDD00008 |
[Entity<T>] and [AggregateRoot<T>] on the same class | DDD00009 |
| A setter on a generated collection property | DDD00020 |
| A field or property typed as another aggregate root | 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 has the argument. If you are not
ready to have it now, turn it off for the project and come back to it:
<PropertyGroup>
<NoWarn>$(NoWarn);DDD00021</NoWarn>
</PropertyGroup>
Two more exist and neither can fire on an upgraded 2.x solution: DDD00022 and DDD00023 are silent until two assemblies declare themselves modules. See section 16.
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.
11. Entities gain an invariant seam
Every [Entity<T>] and [AggregateRoot<T>] now gets five generated members:
partial void CheckInvariants();
public override void EnsureInvariants();
public override IReadOnlyList<InvariantViolation> GetInvariantViolations();
public override void EnsureOwnInvariants();
public override IReadOnlyList<InvariantViolation> 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.
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:
[AggregateRoot<OrderId>]
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.
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:
<!-- 2.0.22 -->
<PackageReference Include="DDDToolkit" Version="2.0.22" />
<PackageReference Include="DDDToolkit.EntityFramework" Version="2.0.22" />
<!-- 3.0.0 -->
<PackageReference Include="Temp.DDDToolkit" Version="3.0.0" />
<PackageReference Include="Temp.DDDToolkit.EntityFramework" Version="3.0.0" />
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<TSchemaType> 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<TRuntimeType, TSchemaType>() | Microsoft.Extensions.DependencyInjection |
AddTypeConverter<T>() | Microsoft.Extensions.DependencyInjection |
IChangeTypeProvider and the ChangeType delegate | HotChocolate.Utilities |
[InterfaceType<T>] 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.
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, which is MIT and
source generated, and there is a DDDToolkit.Mediator package that writes the dispatch delegate for
you:
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:
options.DispatchInProcess(async (sp, events, cancellationToken) =>
{
var mediator = sp.GetRequiredService<IMediator>();
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<T>] 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:
[EntityId<Guid>("ORD")]
public partial record OrderId; // 2.x, still works
[EntityId<Guid>("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<T>, IComparable<T>, 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:
[AggregateRoot<Guid>("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.
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]:
private readonly List<OrderLine> _lines = new(); // 2.x
public IReadOnlyList<OrderLine> Lines => _lines.AsReadOnly();
public partial IReadOnlyList<OrderLine> 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:
public List<Order> Orders { get; private set; } = new(); // any caller can Add
public partial IReadOnlyList<Order> Orders { get; } // only the aggregate can
IReadOnlyList<T>, IReadOnlyCollection<T>, IEnumerable<T> and IReadOnlySet<T> are supported.
A setter is an error (DDD00020). See
Entities and aggregates.
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:
[assembly: Module("Ordering")]
[ModuleContract]
[EntityId<Guid>("CUS")]
public readonly partial record struct CustomerId;
An analyzer then reports where one module names another module's unpublished type
(DDD00022) or holds another module's entity as stored state
(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 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:
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 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.
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.
What did not change
[ValueObject]and[SingleValueObject<T>]keep their shape, theirValidtwin,ToValid()andInvalidValueObjectException.[DontCompare]and[Internal]mean what they always meant.- The
ColumnLengthargument of[EntityId<T>]still becomesHaveMaxLength. - The generated
Add{Module}Converterskeeps its name and its place, andDDD_Modulestill names it. Entity<TId>.Idstill has aprotected set, so a 2.x constructor that wroteId = idafterbase()still compiles.base(id)is the better form.- Validation still runs through
protected bool Validate(), or a generated body whenDDDToolkit.FluentValidationis referenced. There is a second overload now,protected override void Validate(ValidationErrorBuilder errors), and a non-throwingTryToValidbesideToValid(), but nothing you already wrote has to move. See 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
- Retarget every project to
net10.0and pin the SDK. - Update the package references:
DDDToolkit.*toTemp.DDDToolkit.*3.0.0, andHotChocolate.ExecutiontoHotChocolate. - Build. Fix the
IDomainEventerrors by deriving your events fromDomainEvent. - Fix the
AddDomainEventerrors by renaming toRaiseDomainEventinside aggregates, and by giving the aggregate a method where the call was outside one. - Move any events raised by child entities up to their root.
- Fix the
ClearDomainEventserrors with a cast toIHasDomainEvents, or delete the code and use the interceptor. - 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
NoWarnif the list is long. - Rename any member of your own called
CheckInvariants,EnsureInvariants,GetInvariantViolations,EnsureOwnInvariantsorGetOwnInvariantViolations; all five names are now generated onto every entity. - Replace
UseDomainEvents(...)andAddDomainEventInterceptor(...)withAddDDDToolkitEntityFramework(...)andUseDDDToolkit(...). - Add
AddDDDToolkitConventions()toConfigureConventions. - Scaffold a migration for the
Versioncolumn. - Read section 6 and decide, per handler, whether it can run before the commit. Move the ones that cannot to the outbox.
- 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.