We’d like to implement an undo/redo feature in our application. I’m aware that this can be a complex issue, particularly when dealing with dependent entities. And yet it’s a feature for which most applications require a solution.
Have you written anything on this subject before? (I haven’t been able to find anything, unfortunately)
How would you implement this using the existing ABP functionality?
EntityChanges and EntityPropertyChanges would certainly be one possible approach, but this is probably not quite enough to implement the functionality fully.
Would EF’s change tracking be suitable for storing the complete changes of a transaction, e.g. in a cache? Or does that stop working once you’ve called SaveChanges?
Do you have any ideas on how you would solve this (best practices) and whether there might already be something implemented in ABP that could be used?
Another big question is how to go about this when ‘delete cascading’ is configured… or when the entities are not fully audited entities.
I look forward to your answer.
Best, Adrian
4 Answer(s)
-
0
ABP does not provide a built-in “undo/redo” feature (like an application-level time machine for domain changes). ABP’s built-in features that are closest are Unit of Work (transaction rollback) and Audit Logging / Entity Change History—but those are primarily for tracking, not for reverting.
Important distinction:
- Rollback (within the same request/UoW): ABP already supports this via Unit of Work + transactions.
- Undo/Redo (after
SaveChanges/after commit): you need an explicit domain-level design (typically event sourcing / command log / snapshots), because the DB state is already committed.
Recommended approach (practical, ABP-aligned)
A) Use ABP Audit Logging as the data source for “undo” (when feasible)
If your undo requirements are “user can revert what they just changed”, and your aggregates are not too complex, the most ABP-native approach is:
- Enable entity change tracking (Audit Logging with Entity Changes).
- For each “undoable action”, store a reference to the produced audit log / entity change set (e.g., AuditLogId).
- Implement an Undo AppService that:
- Loads the relevant entity changes (old/new values),
- Applies reverse operations to entities in a new Unit of Work,
- Saves changes.
What you must handle yourself:
- Ordering (e.g., undo child before parent when needed).
- Creates/Deletes: You need custom logic:
- Undo “create” → delete the created entity
- Undo “delete” → re-create (hard) or un-soft-delete (if soft delete)
- Navigation/collection changes: may not be fully representable as simple property diffs.
- Side effects (events, integrations, background jobs): you need to decide whether undo should compensate those too.
This becomes a compensating action system, not purely “replay EF changes”.
B) For complex domains: prefer a Command Log / Domain Events (event-sourcing style)
If you have dependent entities, cascading deletes, invariants, external side effects, etc., the robust approach is:
- Model “undoable operations” as commands (e.g.,
ChangeCustomerAddress,CancelOrder,AddOrderLine). - Persist command intent + required previous state (or domain events).
- Implement compensating commands (redo/undo) explicitly.
ABP supports clean layering for this (Application Service methods as commands, Domain layer enforcing invariants). Audit logs can still be used for traceability, but not as the only truth for undo.
About EF Core change tracking / caching changes
- EF Core’s change tracker is not suitable as an undo storage beyond the active
DbContextlifetime. - After you call
SaveChanges, EF Core updates “original values” and entity states; it’s not a reliable “diff store” to replay later. - Also, in ABP each Unit of Work typically scopes a DbContext; once the request ends, that tracking is gone.
So: don’t store EF change tracker output in cache as your undo source for committed operations.
Cascading deletes / not fully audited entities
- Cascading delete is exactly where “diff-based undo” becomes dangerous:
- The DB may delete many dependent rows automatically; audit logs may not capture everything the way you need to reconstruct reliably.
- Undoing requires either (a) soft delete everywhere, or (b) explicit domain logic to restore the graph.
- If entities are not audited / not selected for entity history, you simply won’t have the old values in ABP’s audit logs. For an audit-log-driven undo, you must ensure the involved entities are included in entity history tracking.
What ABP already gives you (and how it fits)
- Unit of Work: guarantees atomic commit/rollback during the operation, not undo after commit.
- Audit Logging + Entity Changes: can record “what changed” (old/new values) and can be used as an input for compensating logic, but ABP does not implement the compensation automatically.
Sources
- https://abp.io/docs/latest/framework/infrastructure/audit-logging
- https://abp.io/docs/latest/framework/architecture/domain-driven-design/unit-of-work
This response was generated by AI. If it didn’t solve your problem, feel free to post a new answer — our support team will jump in to help you soon.
Markdown supported.Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post) -
0
Hi,
ABP doesn't have an undo/redo feature, and we haven't written anything on this topic.
I wouldn't build it on
EntityChange/EntityPropertyChange. A few things make it unusable as a restore source:- The audit log is written in its own unit of work, and
HideErrorsistrueby default, so a failed audit save doesn't fail the business transaction. Your restore data can be silently missing. - Old and new values are truncated at 512 characters.
- A soft deleted entity produces a
Deletedentity change with zero property changes, so the values from before the delete are not there. - Navigation and collection changes only store the keys of the related rows, not their content.
- When you delete a parent and the children go away through cascade delete, the children are not in the log at all. ABP builds the change list from the change tracker before
SaveChanges, and EF marks cascade deleted children as deleted duringSaveChanges.
EF change tracking doesn't work for this either.
SaveChangesusesacceptAllChangesOnSuccess: true, so when it returns the entries are back toUnchangedand the original values have been replaced by the current ones. That's also why ABP reads the tracker right before callingSaveChanges. Caching the entries doesn't help, theDbContextis gone at the end of the unit of work anyway.What works is a separate implementation in your own application layer, with the aggregate root as the unit of undo, not the entity and not the property:
- Before each undoable operation, serialize the whole aggregate (loaded with details) and store it together with the "after" snapshot in your own table, in the same transactional unit of work as the operation itself.
- Undo means writing the "before" snapshot back through your own handler, not replaying reverse property diffs.
- Make the aggregates you want to undo-delete soft deletable, then undoing a delete is just clearing the deletion flags.
public class UndoOperation : AggregateRoot<Guid> { public Guid? UserId { get; set; } public string AggregateName { get; set; } public string AggregateId { get; set; } public string BeforeJson { get; set; } public string AfterJson { get; set; } public bool IsUndone { get; set; } public DateTime Time { get; set; } protected UndoOperation() { } public UndoOperation(Guid id) : base(id) { } } public class UndoRecorder : ITransientDependency { private readonly IRepository<UndoOperation, Guid> _undoOperationRepository; private readonly IJsonSerializer _jsonSerializer; private readonly ICurrentUser _currentUser; private readonly IGuidGenerator _guidGenerator; private readonly IClock _clock; public UndoRecorder( IRepository<UndoOperation, Guid> undoOperationRepository, IJsonSerializer jsonSerializer, ICurrentUser currentUser, IGuidGenerator guidGenerator, IClock clock) { _undoOperationRepository = undoOperationRepository; _jsonSerializer = jsonSerializer; _currentUser = currentUser; _guidGenerator = guidGenerator; _clock = clock; } public string Snapshot<TAggregateRoot>(TAggregateRoot aggregateRoot) where TAggregateRoot : class { return aggregateRoot == null ? null : _jsonSerializer.Serialize(aggregateRoot); } public async Task RecordAsync(string aggregateName, string aggregateId, string before, string after) { await _undoOperationRepository.InsertAsync( new UndoOperation(_guidGenerator.Create()) { UserId = _currentUser.Id, AggregateName = aggregateName, AggregateId = aggregateId, BeforeJson = before, AfterJson = after, Time = _clock.Now }, autoSave: false ); } }Then put the restore logic on the aggregate itself and record in your mutating methods:
public class Book : FullAuditedAggregateRoot<Guid> { public string Name { get; set; } public List<Chapter> Chapters { get; set; } public void RestoreFrom(Book snapshot) { Name = snapshot.Name; IsDeleted = false; DeletionTime = null; DeleterId = null; Chapters.RemoveAll(c => snapshot.Chapters.All(s => s.Id != c.Id)); foreach (var snapshotChapter in snapshot.Chapters) { var chapter = Chapters.FirstOrDefault(c => c.Id == snapshotChapter.Id); if (chapter == null) { Chapters.Add(snapshotChapter); } else { chapter.Title = snapshotChapter.Title; } } } } public class BookAppService : ApplicationService { // ... constructor injection of the repositories, UndoRecorder, IJsonSerializer and IDataFilter [UnitOfWork(isTransactional: true)] public async Task UpdateAsync(Guid id, UpdateBookDto input) { var book = await _bookRepository.GetAsync(id, includeDetails: true); var before = _undoRecorder.Snapshot(book); book.Name = input.Name; await _bookRepository.UpdateAsync(book); await CurrentUnitOfWork.SaveChangesAsync(); await _undoRecorder.RecordAsync(nameof(Book), id.ToString(), before, _undoRecorder.Snapshot(book)); } [UnitOfWork(isTransactional: true)] public async Task DeleteAsync(Guid id) { var book = await _bookRepository.GetAsync(id, includeDetails: true); var before = _undoRecorder.Snapshot(book); await _bookRepository.DeleteAsync(book); await CurrentUnitOfWork.SaveChangesAsync(); await _undoRecorder.RecordAsync(nameof(Book), id.ToString(), before, null); } [UnitOfWork(isTransactional: true)] public async Task UndoAsync(Guid operationId) { var operation = await _undoOperationRepository.GetAsync(operationId); var snapshot = _jsonSerializer.Deserialize<Book>(operation.BeforeJson); using (_dataFilter.Disable<ISoftDelete>()) { var book = await _bookRepository.FindAsync(snapshot.Id, includeDetails: true); if (book == null) { await _bookRepository.InsertAsync(snapshot); } else { book.RestoreFrom(snapshot); await _bookRepository.UpdateAsync(book); } } operation.IsUndone = true; await _undoOperationRepository.UpdateAsync(operation); } }Redo is the same method with
AfterJsoninstead ofBeforeJson.On the three points you asked about:
- Dependent entities: define the snapshot along the aggregate boundary and always load it with
includeDetails: true.RestoreFromis where you reconcile the child collection by id, and that part is domain specific, there is no generic version of it. - Delete cascading: load the children into the snapshot before the delete, not after. If the aggregate is soft deletable you don't have to rebuild them at all, the rows are still in the database.
- Entities that are not fully audited: it doesn't matter, this approach doesn't use auditing. Keep audit logging for "who changed what and when" and keep these tables for restoring.
Two things worth planning for from the start: store the concurrency stamp (or a hash) of the current state with the operation and refuse the undo when it no longer matches, otherwise you silently overwrite what another user did in the meantime. And put a version number on the snapshot payload, because renaming or removing a property later makes the old JSON unusable.
Thanks
Markdown supported.Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post) - The audit log is written in its own unit of work, and
-
0
Thank you very much for the detailed explanation and code example :-) At the moment, we still need a bit more time to implement this. It isn’t a top priority just yet.
Wouldn’t that be a great feature for an extension to the ABP framework? A generic solution like that would probably be useful to many people and would be a useful addition. Please add it to the wish list :-)
Markdown supported.Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post)