Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Entity Tree and Undo-Redo Architecture

Undo-redo systems are harder than they first appear. A robust implementation must handle complex scenarios like nested entities, cascading changes, and maintaining data integrity during undos and redos. Qleany’s undo-redo architecture is designed to simplify these challenges by enforcing clear rules on how entities relate to each other in the context of undo-redo operations.

Entities in Qleany form a tree structure based on strong (ownership) relationships. This tree organization directly influences how undo-redo works across your application.

Quick Reference

Before diving into the details, here is a summary of the two approaches Qleany supports:

AspectApproach A: Document-ScopedApproach B: Panel-Scoped
Stack lifecycleCreated when document opens, destroyed when it closesCreated when panel gains focus, cleared on focus loss or after undo
History depthUnlimitedOne command
Redo behaviorFull redo history until new actionSingle-use, lost on focus change
Deletion handlingOptional stack-based or soft-delete with toastSoft-delete with timed toast
User expectation“Undo my last change to this document”“Undo my immediate mistake”
Best forIDEs, creative suites, document editorsForm-based apps, simple tools

Qleany itself uses Approach B. Skribisto uses Approach A.

My Recommendations

Do not use a single, linear undo-redo stack for the entire application except in the most basic cases. As Admiral Ackbar said: “It’s a trap!”

Think about interactions from the user’s perspective: they expect undo and redo to apply to specific contexts rather than globally. A monolithic stack leads to confusion and unintended consequences. If a user is editing a document and undoes an action, they do not expect that to also undo changes in unrelated settings or other documents.

Instead, each context should have its own undo-redo stack. The question is how to define “context.” Qleany supports two approaches, described below, suited to different application types. Both use the same generated infrastructure; they differ only in when and where stacks are created and destroyed.

For destructive operations such as deleting entities, Qleany supports cascading deletions and their undoing. If you delete a parent entity, all its strongly-owned children are also deleted, and you can undo that. At first, I used a database savepoint to be restored on undo, but the savepoint impacted non-undoable data as well, leading to confusion and unexpected behavior. Now, the create, createOrphans, remove and setRelationshipsIds commands use cascading snapshots of the individual tables to restore the database state before the operation.

Yet this behavior may be not what the user expects. Instead, you can use soft-deletion with timed recovery, described in the Soft Deletion section below.

Two Approaches to Undo-Redo

Approach A: Document-Scoped Stack

The stack is created when a document, workspace, or undoable trunk is loaded and destroyed when it closes. All UI panels editing entities within that trunk share the same stack.

This approach provides full undo history across the entire document. When the user presses Ctrl+Z, the application undoes the most recent change to the document regardless of which panel made it. This matches the behavior of professional tools like Qt Creator, Blender, and Adobe applications.

Redo works symmetrically: the user can redo any undone action until they perform a new action, which clears the redo stack.

Lifecycle. Create the stack when the document opens. Destroy it when the document closes. All panels resolve the same stack_id by looking up the document they are editing.

User expectation. “Undo my last change to this document.”

Best suited for. Complex applications, professional tools, creative suites, IDEs, and any application where users expect deep undo history and work on persistent documents over extended sessions.

Approach B: Panel-Scoped Stack, Length 1

The stack is created when a panel becomes active and cleared or destroyed when the panel loses focus or after a single undo executes. Each panel manages its own short-lived stack holding at most one command.

This approach provides immediate mistake recovery without maintaining history. When the user presses Ctrl+Z, the application undoes only their most recent action in that panel. After one undo, the stack is empty. This matches modern application patterns where undo is an “oops” button rather than a time-travel mechanism.

Redo is effectively single-use in this approach. After undoing, the user can redo immediately, but switching focus or performing any new action clears the redo slot. This is an acceptable trade-off for the simplicity gained.

Lifecycle. Create the stack when the panel gains focus. Clear or destroy it when the panel loses focus or after undo executes.

User expectation. “Undo my immediate mistake.”

Best suited for. Simpler applications, form-based interfaces, and applications where deep undo history would cause more confusion than benefit.

Entity Properties

With the approach chosen, configure your entities using these properties relevant to undo-redo:

PropertyTypeDefaultEffect
undoableboolfalseAdds undo/redo support to the entity’s controller
allow_direct_accessbooltrueGenerates entity files in direct_access/ for UI access
single_modelboolfalseGenerates Single{Entity} wrapper for QML (C++/Qt only)

Undo-Redo Rules

The undo-redo system follows strict inheritance rules through the entity tree:

  1. A non-undoable entity cannot have an undoable entity as parent (strong relationship)
  2. All children of an undoable entity must also be undoable
  3. Weak relationships (references) can point to any entity regardless of undo status

These rules ensure that when you undo an operation on a parent entity, all its strongly-owned children can be consistently rolled back.

What happens if you violate these rules? The code will generate, compile, and run — Qleany does not enforce these rules at generation time. However, undo/redo stacks will become inconsistent. For example, if you place non-undoable persistent settings as a child of an undoable entity, those settings could be unexpectedly undone by cascade when the user undoes the parent. You do not want application settings disappearing because the user undid an unrelated action.

Follow these rules strictly. If data should not participate in undo (like settings), place it in a separate non-undoable trunk — do not nest it under undoable entities.

A basic validation system checks some of these rules at generation time. It is being improved to perform checks at load time as well.

Entity Tree Configurations

Depending on your application’s complexity, you can organize your entity tree in three ways.

Configuration 1: No Undo-Redo

For simple applications where undo-redo is not needed, all entities are non-undoable.

Root (undoable: false)
├── Settings
├── Project
│   ├── Document
│   └── Asset
└── Cache
entities:
  - name: Root
    inherits_from: EntityBase
    undoable: false
    fields:
      - name: settings
        type: entity
        entity: Settings
        relationship: one_to_one
        strong: true
      - name: projects
        type: entity
        entity: Project
        relationship: ordered_one_to_many
        strong: true

Even without user-facing undo-redo, the undo system must be initialized internally as it is used for transaction management.

Configuration 2: Single Undoable Trunk

For applications where all user data should support undo-redo, the root is non-undoable with a single undoable trunk beneath it.

Root (undoable: false)
└── Workspace (undoable: true)     ← All user data under this trunk
    ├── Project (undoable: true)
    │   ├── Document (undoable: true)
    │   └── Asset (undoable: true)
    └── Tag (undoable: true)
entities:
  - name: Root
    inherits_from: EntityBase
    undoable: false
    fields:
      - name: workspace
        type: entity
        entity: Workspace
        relationship: one_to_one
        strong: true

  - name: Workspace
    inherits_from: EntityBase
    undoable: true
    fields:
      - name: projects
        type: entity
        entity: Project
        relationship: ordered_one_to_many
        strong: true
      - name: tags
        type: entity
        entity: Tag
        relationship: one_to_many
        strong: true

  - name: Project
    inherits_from: EntityBase
    undoable: true
    fields:
      - name: documents
        type: entity
        entity: Document
        relationship: ordered_one_to_many
        strong: true

With Approach A, create one stack when the Workspace loads. All panels share this stack, and the user has full undo history across the entire workspace.

With Approach B, each panel creates and manages its own stack independently. The user has immediate undo within each panel, with deletions handled via toast notifications.

Configuration 3: Multiple Trunks

For applications that need both undoable user data and non-undoable system data, or for multi-document applications where each document should have independent undo history, the root has multiple trunks.

Root (undoable: false)
├── System (undoable: false)       ← Non-undoable trunk
│   ├── Settings (undoable: false)
│   ├── RecentFiles (undoable: false)
│   └── SearchResults (undoable: false)
│
└── Workspace (undoable: true)     ← Undoable trunk
    ├── Event (undoable: true)
    │   └── Attendee (undoable: true)
    └── Calendar (undoable: true)

For multi-document applications:

Root (undoable: false)
├── System (undoable: false)
├── Document A (undoable: true)    ← Stack A
├── Document B (undoable: true)    ← Stack B
└── Document C (undoable: true)    ← Stack C
entities:
  - name: Root
    inherits_from: EntityBase
    undoable: false
    fields:
      - name: system
        type: entity
        entity: System
        relationship: one_to_one
        strong: true
      - name: workspace
        type: entity
        entity: Workspace
        relationship: one_to_one
        strong: true

  - name: System
    inherits_from: EntityBase
    undoable: false
    allow_direct_access: true
    fields:
      - name: settings
        type: entity
        entity: Settings
        relationship: one_to_one
        strong: true
      - name: recentFiles
        type: entity
        entity: RecentFile
        relationship: ordered_one_to_many
        strong: true
      - name: searchResults
        type: entity
        entity: SearchResult
        relationship: one_to_many
        strong: true

  - name: Settings
    inherits_from: EntityBase
    undoable: false
    fields:
      - name: theme
        type: string
      - name: language
        type: string

  - name: SearchResult
    inherits_from: EntityBase
    undoable: false
    allow_direct_access: false
    fields:
      - name: query
        type: string
      - name: matchedItem
        type: entity
        entity: Event
        relationship: many_to_one

  - name: Workspace
    inherits_from: EntityBase
    undoable: true
    fields:
      - name: events
        type: entity
        entity: Event
        relationship: ordered_one_to_many
        strong: true
      - name: calendars
        type: entity
        entity: Calendar
        relationship: one_to_many
        strong: true

  - name: Event
    inherits_from: EntityBase
    undoable: true
    single_model: true
    fields:
      - name: title
        type: string
      - name: attendees
        type: entity
        entity: Attendee
        relationship: one_to_many
        strong: true
        list_model: true

With Approach A, each document gets its own stack. Ctrl+Z in Document A’s editor undoes only Document A’s changes. This provides natural contextual undo at the document level.

With Approach B, the multi-document structure is less relevant since each panel manages its own immediate-undo stack regardless of which document it edits.

Here is the section, written to sit between Configuration 3 and Cross-Trunk References:


Breaking the Mold

The three configurations above are the patterns I recommend and use myself. They are not the only ones the infrastructure supports.

Qleany’s generated code does not enforce a single Root entity. It does not enforce tree-structured ownership at all. The repository layer provides createOrphans alongside create. The undo/redo system keys its stacks by integer ID, not by position in a tree. The snapshot/restore system captures whatever entity graph it finds. Nothing checks that your entities form a coherent tree at runtime.

This means you can do things the configurations above don’t show:

Multiple independent roots. You can create several root-like entities, each owning a separate subtree with its own undo stack. Think of a multi-workspace IDE where each workspace is truly independent — its own entities, its own undo history, no shared state. This works. I haven’t needed it in Skribisto or Qleany, but the infrastructure won’t stop you.

Flat orphan entities. You can skip the tree model entirely and use createOrphans for everything, managing relationships through weak references. For a simple utility with a handful of entities and no undo/redo, this is less ceremony than setting up a Root → Workspace hierarchy you don’t need.

Hybrid approaches. A tree for your main domain model, orphan entities for transient data that doesn’t belong in the tree. The infrastructure doesn’t care.

So why do I recommend the tree model so insistently?

Because the tree model gives you things for free that you must handle manually without it. Cascade deletion follows ownership: delete a parent, all strongly-owned children are deleted. Snapshot/restore captures the full subtree: undo a deletion, everything comes back including nested children and their junction relationships. Undo stack scoping maps naturally to tree branches: one stack per document, one stack per workspace.

Without the tree, you take on these responsibilities yourself. Orphan entities have no owner to cascade from, you must track and delete them explicitly. A parent’s snapshot does not capture entities outside a tree, you must manage their lifecycle in your use case logic. Undo stack assignment becomes your problem rather than a natural consequence of the data structure.

None of this is impossible. It’s just work that the tree model handles for you.

If you deviate from the prescribed configurations, the undo/redo rules from the previous section still apply. A non-undoable entity should not be strongly owned by an undoable entity, regardless of your tree topology. The infrastructure won’t warn you. The undo stacks will just become inconsistent, and you’ll spend an afternoon figuring out why.

My advice: start with the tree model. If you later find it too rigid for a specific part of your application, relax it locally — use orphans for that part, keep the tree for the rest. Don’t start with a flat model and try to add structure later. It’s easier to remove structure than to add it.


Cross-Trunk References

Non-undoable entities can hold weak references (many_to_one, many_to_many) to undoable entities. This is useful for search results, recent items, or bookmarks that point to user data without owning it.

- name: SearchResult
  undoable: false
  fields:
    - name: matchedEvent
      type: entity
      entity: Event
      relationship: many_to_one

The reverse is also true: undoable entities can reference non-undoable entities, such as referencing a Settings entity for default values.

Soft Deletion

Definition: deletions are handled outside the undo stack using soft-deletion with timed hard-deletion.

To implement soft deletion, add an activated boolean field to your entities. When “deleting” an entity, set this flag to false instead of removing it from the database. Your UI filters out entities where activated is false, effectively hiding them from the user.

For immediate recovery, display a toast notification with an “Undo” action for a few seconds after deletion, typically three seconds. Maintain a timer for each soft-deleted entity. If the user clicks “Undo” within the timeout window, restore the entity by setting activated back to true and cancel the timer. If the timeout expires, perform the hard-delete.

This pattern is time-bounded rather than focus-bounded. The user can switch panels, notice the toast still visible, and click “Undo” within the window. It matches user expectations from applications like Gmail, Slack, and Notion.

For longer-term recovery, you can implement a trash bin with a dedicated entity that holds references to soft-deleted items:

Idtrashed_dateentity_typeentity_id
12024-01-01Document42
22024-01-02Car7

Users can then restore items from the trash bin or permanently delete them. Permanently emptying the trash clears all undo-redo stacks, which is acceptable since permanent deletion is a non-undoable action from the user’s perspective as well.

For Approach A, you may alternatively implement deletion undo through the stack if your application requires full undo history for deletions, but the soft-deletion pattern remains simpler and avoids cascade-reversal complexity.

Note : Soft deletion isn’t baked-in to Qleany’s generated code. You must implement the activated field, filtering logic, toast UI, and timer management yourself. I only provide this pattern as a recommended best practice. To only display non-deleted entities, you can use QAbstractProxyModel in C++/Qt or filter models in QML.

Choosing Your Approach

The key questions to ask are: Do you have data that should not participate in undo? Do users expect deep history or just immediate mistake recovery? Will users work on multiple independent documents simultaneously?

Application TypeEntity ConfigurationRecommended Approach
Simple utilityNo undo-redoNeither
Form-based appSingle undoable trunkApproach B
Document editorSingle undoable trunkApproach A
Multi-document IDEMultiple undoable trunksApproach A
Creative suiteMultiple undoable trunksApproach A

Settings, preferences, search results, and caches belong in non-undoable trunks. User-created content belongs in undoable trunks. Temporary UI state belongs outside the entity tree entirely or in non-undoable trunks.


For implementation details of the undo/redo system including command infrastructure, async execution, and composite commands, see Generated Infrastructure - C++/Qt or Generated Infrastructure - Rust.