Migrate ChangeModel to DI pattern.

Google-Bug-Id: b/213870339
Change-Id: I506e035472385b3c29680a3da7393dcea2b15200
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
index 0c07abb..8694e11 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
@@ -29,7 +29,6 @@
 import '../gr-confirm-submit-dialog/gr-confirm-submit-dialog';
 import '../../../styles/shared-styles';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-change-actions_html';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
@@ -109,6 +108,8 @@
 } from '../../../api/change-actions';
 import {ErrorCallback} from '../../../api/rest';
 import {GrDropdown} from '../../shared/gr-dropdown/gr-dropdown';
+import {resolve, DIPolymerElement} from '../../../models/dependency';
+import {changeModelToken} from '../../../models/change/change-model';
 
 const ERR_BRANCH_EMPTY = 'The destination branch can’t be empty.';
 const ERR_COMMIT_EMPTY = 'The commit message can’t be empty.';
@@ -341,7 +342,7 @@
 
 @customElement('gr-change-actions')
 export class GrChangeActions
-  extends PolymerElement
+  extends DIPolymerElement
   implements GrChangeActionsElement
 {
   static get template() {
@@ -386,7 +387,7 @@
   // Accessed in tests
   readonly jsAPI = getAppContext().jsApiService;
 
-  private readonly changeModel = getAppContext().changeModel;
+  private readonly getChangeModel = resolve(this, changeModelToken);
 
   @property({type: Object})
   change?: ChangeViewChangeInfo;
@@ -1716,43 +1717,45 @@
         new Error('Properties change and changeNum must be set.')
       );
     }
-    return this.changeModel.fetchChangeUpdates(change).then(result => {
-      if (!result.isLatest) {
-        this.dispatchEvent(
-          new CustomEvent<ShowAlertEventDetail>('show-alert', {
-            detail: {
-              message:
-                'Cannot set label: a newer patch has been ' +
-                'uploaded to this change.',
-              action: 'Reload',
-              callback: () => fireReload(this, true),
-            },
-            composed: true,
-            bubbles: true,
-          })
-        );
+    return this.getChangeModel()
+      .fetchChangeUpdates(change)
+      .then(result => {
+        if (!result.isLatest) {
+          this.dispatchEvent(
+            new CustomEvent<ShowAlertEventDetail>('show-alert', {
+              detail: {
+                message:
+                  'Cannot set label: a newer patch has been ' +
+                  'uploaded to this change.',
+                action: 'Reload',
+                callback: () => fireReload(this, true),
+              },
+              composed: true,
+              bubbles: true,
+            })
+          );
 
-        // Because this is not a network error, call the cleanup function
-        // but not the error handler.
-        cleanupFn();
+          // Because this is not a network error, call the cleanup function
+          // but not the error handler.
+          cleanupFn();
 
-        return Promise.resolve(undefined);
-      }
-      const patchNum = revisionAction ? this.latestPatchNum : undefined;
-      return this.restApiService
-        .executeChangeAction(
-          changeNum,
-          method,
-          actionEndpoint,
-          patchNum,
-          payload,
-          handleError
-        )
-        .then(response => {
-          cleanupFn.call(this);
-          return response;
-        });
-    });
+          return Promise.resolve(undefined);
+        }
+        const patchNum = revisionAction ? this.latestPatchNum : undefined;
+        return this.restApiService
+          .executeChangeAction(
+            changeNum,
+            method,
+            actionEndpoint,
+            patchNum,
+            payload,
+            handleError
+          )
+          .then(response => {
+            cleanupFn.call(this);
+            return response;
+          });
+      });
   }
 
   _handleCherrypickTap() {
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
index 34bfbc5..1727ed9 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
@@ -196,10 +196,11 @@
   hasAttention,
 } from '../../../utils/attention-set-util';
 import {listen} from '../../../services/shortcuts/shortcuts-service';
-import {LoadingStatus} from '../../../services/change/change-model';
+import {LoadingStatus} from '../../../models/change/change-model';
 import {commentsModelToken} from '../../../models/comments/comments-model';
 import {resolve, DIPolymerElement} from '../../../models/dependency';
 import {checksModelToken} from '../../../models/checks/checks-model';
+import {changeModelToken} from '../../../models/change/change-model';
 
 const MIN_LINES_FOR_COMMIT_COLLAPSE = 18;
 
@@ -608,7 +609,7 @@
   readonly userModel = getAppContext().userModel;
 
   // Private but used in tests.
-  readonly changeModel = getAppContext().changeModel;
+  readonly getChangeModel = resolve(this, changeModelToken);
 
   private readonly routerModel = getAppContext().routerModel;
 
@@ -689,7 +690,7 @@
       })
     );
     this.subscriptions.push(
-      this.changeModel.change$.subscribe(change => {
+      this.getChangeModel().change$.subscribe(change => {
         // The change view is tied to a specific change number, so don't update
         // _change to undefined.
         if (change) this._change = change;
@@ -1913,7 +1914,7 @@
 
     const prefCompletes = this._getPreferences();
     await until(
-      this.changeModel.changeLoadingStatus$,
+      this.getChangeModel().changeLoadingStatus$,
       status => status === LoadingStatus.LOADED
     );
     this._prefs = await prefCompletes;
@@ -2078,7 +2079,7 @@
     // Resolves when the change detail and the edit patch set (if available)
     // are loaded.
     const detailCompletes = until(
-      this.changeModel.changeLoadingStatus$,
+      this.getChangeModel().changeLoadingStatus$,
       status => status === LoadingStatus.LOADED
     );
     this.performPostChangeLoadTasks();
@@ -2328,54 +2329,56 @@
         return;
       }
       const change = this._change;
-      this.changeModel.fetchChangeUpdates(change).then(result => {
-        let toastMessage = null;
-        if (!result.isLatest) {
-          toastMessage = ReloadToastMessage.NEWER_REVISION;
-        } else if (result.newStatus === ChangeStatus.MERGED) {
-          toastMessage = ReloadToastMessage.MERGED;
-        } else if (result.newStatus === ChangeStatus.ABANDONED) {
-          toastMessage = ReloadToastMessage.ABANDONED;
-        } else if (result.newStatus === ChangeStatus.NEW) {
-          toastMessage = ReloadToastMessage.RESTORED;
-        } else if (result.newMessages) {
-          toastMessage = ReloadToastMessage.NEW_MESSAGE;
-          if (result.newMessages.author?.name) {
-            toastMessage += ` from ${result.newMessages.author.name}`;
+      this.getChangeModel()
+        .fetchChangeUpdates(change)
+        .then(result => {
+          let toastMessage = null;
+          if (!result.isLatest) {
+            toastMessage = ReloadToastMessage.NEWER_REVISION;
+          } else if (result.newStatus === ChangeStatus.MERGED) {
+            toastMessage = ReloadToastMessage.MERGED;
+          } else if (result.newStatus === ChangeStatus.ABANDONED) {
+            toastMessage = ReloadToastMessage.ABANDONED;
+          } else if (result.newStatus === ChangeStatus.NEW) {
+            toastMessage = ReloadToastMessage.RESTORED;
+          } else if (result.newMessages) {
+            toastMessage = ReloadToastMessage.NEW_MESSAGE;
+            if (result.newMessages.author?.name) {
+              toastMessage += ` from ${result.newMessages.author.name}`;
+            }
           }
-        }
 
-        // We have to make sure that the update is still relevant for the user.
-        // Since starting to fetch the change update the user may have sent a
-        // reply, or the change might have been reloaded, or it could be in the
-        // process of being reloaded.
-        const changeWasReloaded = change !== this._change;
-        if (
-          !toastMessage ||
-          this._loading ||
-          changeWasReloaded ||
-          !this.isViewCurrent
-        ) {
-          this._startUpdateCheckTimer();
-          return;
-        }
+          // We have to make sure that the update is still relevant for the user.
+          // Since starting to fetch the change update the user may have sent a
+          // reply, or the change might have been reloaded, or it could be in the
+          // process of being reloaded.
+          const changeWasReloaded = change !== this._change;
+          if (
+            !toastMessage ||
+            this._loading ||
+            changeWasReloaded ||
+            !this.isViewCurrent
+          ) {
+            this._startUpdateCheckTimer();
+            return;
+          }
 
-        this._cancelUpdateCheckTimer();
-        this.dispatchEvent(
-          new CustomEvent<ShowAlertEventDetail>('show-alert', {
-            detail: {
-              message: toastMessage,
-              // Persist this alert.
-              dismissOnNavigation: true,
-              showDismiss: true,
-              action: 'Reload',
-              callback: () => fireReload(this, true),
-            },
-            composed: true,
-            bubbles: true,
-          })
-        );
-      });
+          this._cancelUpdateCheckTimer();
+          this.dispatchEvent(
+            new CustomEvent<ShowAlertEventDetail>('show-alert', {
+              detail: {
+                message: toastMessage,
+                // Persist this alert.
+                dismissOnNavigation: true,
+                showDismiss: true,
+                action: 'Reload',
+                callback: () => fireReload(this, true),
+              },
+              composed: true,
+              bubbles: true,
+            })
+          );
+        });
     }, this._serverConfig.change.update_delay * 1000);
   }
 
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
index c432276..170637a 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
@@ -100,7 +100,7 @@
 import {ParsedChangeInfo} from '../../../types/types';
 import {GrRelatedChangesList} from '../gr-related-changes-list/gr-related-changes-list';
 import {ChangeStates} from '../../shared/gr-change-status/gr-change-status';
-import {LoadingStatus} from '../../../services/change/change-model';
+import {LoadingStatus} from '../../../models/change/change-model';
 import {FocusTarget, GrReplyDialog} from '../gr-reply-dialog/gr-reply-dialog';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {GrChangeStar} from '../../shared/gr-change-star/gr-change-star';
@@ -1459,7 +1459,7 @@
     );
 
     element.params = createAppElementChangeViewParams();
-    element.changeModel.setState({
+    element.getChangeModel().setState({
       loadingStatus: LoadingStatus.LOADED,
       change: {
         ...createChangeViewChange(),
@@ -1552,7 +1552,7 @@
 
   test('topic is coalesced to null', async () => {
     sinon.stub(element, '_changeChanged');
-    element.changeModel.setState({
+    element.getChangeModel().setState({
       loadingStatus: LoadingStatus.LOADED,
       change: {
         ...createChangeViewChange(),
@@ -1568,7 +1568,7 @@
 
   test('commit sha is populated from getChangeDetail', async () => {
     sinon.stub(element, '_changeChanged');
-    element.changeModel.setState({
+    element.getChangeModel().setState({
       loadingStatus: LoadingStatus.LOADED,
       change: {
         ...createChangeViewChange(),
@@ -1951,7 +1951,7 @@
   test('_selectedRevision updates when patchNum is changed', () => {
     const revision1: RevisionInfo = createRevision(1);
     const revision2: RevisionInfo = createRevision(2);
-    element.changeModel.setState({
+    element.getChangeModel().setState({
       loadingStatus: LoadingStatus.LOADED,
       change: {
         ...createChangeViewChange(),
@@ -1981,7 +1981,7 @@
     const revision1 = createRevision(1);
     const revision2 = createRevision(2);
     const revision3 = createEditRevision();
-    element.changeModel.setState({
+    element.getChangeModel().setState({
       loadingStatus: LoadingStatus.LOADED,
       change: {
         ...createChangeViewChange(),
@@ -2261,7 +2261,7 @@
         changeNum: TEST_NUMERIC_CHANGE_ID,
         project: TEST_PROJECT_NAME,
       };
-      element.changeModel.setState({
+      element.getChangeModel().setState({
         loadingStatus: LoadingStatus.LOADED,
         change: {
           ...createChangeViewChange(),
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts
index aeff1c7..8cb50fd 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts
@@ -29,9 +29,9 @@
 import {customElement, property, query, state} from 'lit/decorators';
 import {fontStyles} from '../../../styles/gr-font-styles';
 import {subscribe} from '../../lit/subscription-controller';
-import {getAppContext} from '../../../services/app-context';
 import {ParsedChangeInfo} from '../../../types/types';
 import {commentsModelToken} from '../../../models/comments/comments-model';
+import {changeModelToken} from '../../../models/change/change-model';
 import {resolve} from '../../../models/dependency';
 
 @customElement('gr-confirm-submit-dialog')
@@ -65,7 +65,7 @@
 
   private getCommentsModel = resolve(this, commentsModelToken);
 
-  private changeModel = getAppContext().changeModel;
+  private getChangeModel = resolve(this, changeModelToken);
 
   static override get styles() {
     return [
@@ -95,7 +95,7 @@
 
   override connectedCallback() {
     super.connectedCallback();
-    subscribe(this, this.changeModel.change$, x => (this.change = x));
+    subscribe(this, this.getChangeModel().change$, x => (this.change = x));
     subscribe(
       this,
       this.getCommentsModel().threads$,
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
index 441c233..8eb435d 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
@@ -88,6 +88,7 @@
 import {resolve, DIPolymerElement} from '../../../models/dependency';
 import {browserModelToken} from '../../../models/browser/browser-model';
 import {commentsModelToken} from '../../../models/comments/comments-model';
+import {changeModelToken} from '../../../models/change/change-model';
 
 export const DEFAULT_NUM_FILES_SHOWN = 200;
 
@@ -318,7 +319,7 @@
 
   private readonly userModel = getAppContext().userModel;
 
-  private readonly changeModel = getAppContext().changeModel;
+  private readonly getChangeModel = resolve(this, changeModelToken);
 
   private readonly getCommentsModel = resolve(this, commentsModelToken);
 
@@ -396,7 +397,7 @@
       ).subscribe(sizeBarInChangeTable => {
         this._showSizeBars = sizeBarInChangeTable;
       }),
-      this.changeModel.reviewedFiles$.subscribe(reviewedFiles => {
+      this.getChangeModel().reviewedFiles$.subscribe(reviewedFiles => {
         this.reviewed = reviewedFiles ?? [];
       }),
     ];
@@ -748,7 +749,7 @@
       throw new Error('changeNum and patchRange must be set');
     }
 
-    return this.changeModel.setReviewedFilesStatus(
+    return this.getChangeModel().setReviewedFilesStatus(
       this.changeNum,
       this.patchRange.patchNum,
       path,
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
index 6b1ecf2..3c1baa6 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
@@ -51,6 +51,7 @@
   ParsedChangeInfo,
 } from '../../../types/types';
 import {commentsModelToken} from '../../../models/comments/comments-model';
+import {changeModelToken} from '../../../models/change/change-model';
 import {resolve, DIPolymerElement} from '../../../models/dependency';
 
 /**
@@ -255,7 +256,7 @@
   // Private but used in tests.
   readonly getCommentsModel = resolve(this, commentsModelToken);
 
-  private readonly changeModel = getAppContext().changeModel;
+  private readonly changeModel = resolve(this, changeModelToken);
 
   private readonly reporting = getAppContext().reportingService;
 
@@ -271,7 +272,7 @@
       })
     );
     this.subscriptions.push(
-      this.changeModel.change$.subscribe(x => {
+      this.changeModel().change$.subscribe(x => {
         this.change = x;
       })
     );
@@ -281,12 +282,12 @@
       })
     );
     this.subscriptions.push(
-      this.changeModel.repo$.subscribe(x => {
+      this.changeModel().repo$.subscribe(x => {
         this.projectName = x;
       })
     );
     this.subscriptions.push(
-      this.changeModel.changeNum$.subscribe(x => {
+      this.changeModel().changeNum$.subscribe(x => {
         this.changeNum = x;
       })
     );
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
index 1379c7c..a0db9fa 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
@@ -27,7 +27,6 @@
 import '../gr-label-scores/gr-label-scores';
 import '../gr-thread-list/gr-thread-list';
 import '../../../styles/shared-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-reply-dialog_html';
 import {
   GrReviewerSuggestionsProvider,
@@ -119,6 +118,8 @@
 import {getReplyByReason} from '../../../utils/attention-set-util';
 import {addShortcut, Key, Modifier} from '../../../utils/dom-util';
 import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
+import {resolve, DIPolymerElement} from '../../../models/dependency';
+import {changeModelToken} from '../../../models/change/change-model';
 
 const STORAGE_DEBOUNCE_INTERVAL_MS = 400;
 
@@ -167,7 +168,7 @@
 }
 
 @customElement('gr-reply-dialog')
-export class GrReplyDialog extends PolymerElement {
+export class GrReplyDialog extends DIPolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -220,7 +221,7 @@
 
   private readonly reporting = getAppContext().reportingService;
 
-  private readonly changeModel = getAppContext().changeModel;
+  private readonly getChangeModel = resolve(this, changeModelToken);
 
   @property({type: Object})
   change?: ChangeInfo;
@@ -437,11 +438,13 @@
   open(focusTarget?: FocusTarget, quote?: string) {
     assertIsDefined(this.change, 'change');
     this.knownLatestState = LatestPatchState.CHECKING;
-    this.changeModel.fetchChangeUpdates(this.change).then(result => {
-      this.knownLatestState = result.isLatest
-        ? LatestPatchState.LATEST
-        : LatestPatchState.NOT_LATEST;
-    });
+    this.getChangeModel()
+      .fetchChangeUpdates(this.change)
+      .then(result => {
+        this.knownLatestState = result.isLatest
+          ? LatestPatchState.LATEST
+          : LatestPatchState.NOT_LATEST;
+      });
 
     this._focusOn(focusTarget);
     if (quote?.length) {
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
index 490162b..a7d28ce 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
@@ -47,6 +47,8 @@
 import {repeat} from 'lit/directives/repeat';
 import {GrCommentThread} from '../../shared/gr-comment-thread/gr-comment-thread';
 import {getAppContext} from '../../../services/app-context';
+import {resolve} from '../../../models/dependency';
+import {changeModelToken} from '../../../models/change/change-model';
 
 enum SortDropdownState {
   TIMESTAMP = 'Latest timestamp',
@@ -199,14 +201,18 @@
   @state()
   draftsOnly = false;
 
-  private readonly changeModel = getAppContext().changeModel;
+  private readonly getChangeModel = resolve(this, changeModelToken);
 
   private readonly userModel = getAppContext().userModel;
 
-  constructor() {
-    super();
-    subscribe(this, this.changeModel.changeNum$, x => (this.changeNum = x));
-    subscribe(this, this.changeModel.change$, x => (this.change = x));
+  override connectedCallback(): void {
+    super.connectedCallback();
+    subscribe(
+      this,
+      this.getChangeModel().changeNum$,
+      x => (this.changeNum = x)
+    );
+    subscribe(this, this.getChangeModel().change$, x => (this.change = x));
     subscribe(this, this.userModel.account$, x => (this.account = x));
   }
 
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-results.ts b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
index 52162f2..7b78349 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-results.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
@@ -55,7 +55,6 @@
   LabelNameToInfoMap,
   PatchSetNumber,
 } from '../../types/common';
-import {getAppContext} from '../../services/app-context';
 import {spinnerStyles} from '../../styles/gr-spinner-styles';
 import {
   getLabelStatus,
@@ -70,6 +69,7 @@
 import {resolve} from '../../models/dependency';
 import {configModelToken} from '../../models/config/config-model';
 import {checksModelToken} from '../../models/checks/checks-model';
+import {changeModelToken} from '../../models/change/change-model';
 
 /**
  * Firing this event sets the regular expression of the results filter.
@@ -105,13 +105,13 @@
   @state()
   labels?: LabelNameToInfoMap;
 
-  private changeModel = getAppContext().changeModel;
+  private getChangeModel = resolve(this, changeModelToken);
 
   private getChecksModel = resolve(this, checksModelToken);
 
   override connectedCallback() {
     super.connectedCallback();
-    subscribe(this, this.changeModel.labels$, x => (this.labels = x));
+    subscribe(this, this.getChangeModel().labels$, x => (this.labels = x));
   }
 
   static override get styles() {
@@ -560,9 +560,9 @@
   @state()
   repoConfig?: ConfigInfo;
 
-  private changeModel = getAppContext().changeModel;
+  private getChangeModel = resolve(this, changeModelToken);
 
-  private configModel = resolve(this, configModelToken);
+  private getConfigModel = resolve(this, configModelToken);
 
   static override get styles() {
     return [
@@ -587,7 +587,11 @@
 
   override connectedCallback() {
     super.connectedCallback();
-    subscribe(this, this.configModel().repoConfig$, x => (this.repoConfig = x));
+    subscribe(
+      this,
+      this.getConfigModel().repoConfig$,
+      x => (this.repoConfig = x)
+    );
   }
 
   override render() {
@@ -648,7 +652,7 @@
       const end = pointer?.range?.end_line;
       if (start) rangeText += `#${start}`;
       if (end && start !== end) rangeText += `-${end}`;
-      const change = this.changeModel.getChange();
+      const change = this.getChangeModel().getChange();
       assertIsDefined(change);
       const path = pointer.path;
       const patchset = this.result?.patchset as PatchSetNumber | undefined;
@@ -756,7 +760,7 @@
    */
   private isSectionExpandedByUser = new Map<Category, boolean>();
 
-  private readonly changeModel = getAppContext().changeModel;
+  private readonly getChangeModel = resolve(this, changeModelToken);
 
   private readonly getChecksModel = resolve(this, checksModelToken);
 
@@ -779,7 +783,7 @@
     );
     subscribe(
       this,
-      this.changeModel.latestPatchNum$,
+      this.getChangeModel().latestPatchNum$,
       x => (this.latestPatchsetNumber = x)
     );
     subscribe(
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-tab.ts b/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
index 80dab64..49b7a70 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
@@ -22,6 +22,7 @@
   CheckRun,
   checksModelToken,
 } from '../../models/checks/checks-model';
+import {changeModelToken} from '../../models/change/change-model';
 import './gr-checks-runs';
 import './gr-checks-results';
 import {NumericChangeId, PatchSetNumber} from '../../types/common';
@@ -68,7 +69,7 @@
     number | undefined
   >();
 
-  private readonly changeModel = getAppContext().changeModel;
+  private readonly getChangeModel = resolve(this, changeModelToken);
 
   private readonly getChecksModel = resolve(this, checksModelToken);
 
@@ -101,10 +102,14 @@
     );
     subscribe(
       this,
-      this.changeModel.latestPatchNum$,
+      this.getChangeModel().latestPatchNum$,
       x => (this.latestPatchsetNumber = x)
     );
-    subscribe(this, this.changeModel.changeNum$, x => (this.changeNum = x));
+    subscribe(
+      this,
+      this.getChangeModel().changeNum$,
+      x => (this.changeNum = x)
+    );
   }
 
   static override get styles() {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
index 2fdb650..9a167ca 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
@@ -115,11 +115,12 @@
 import {filter, take, switchMap} from 'rxjs/operators';
 import {combineLatest, Subscription} from 'rxjs';
 import {listen} from '../../../services/shortcuts/shortcuts-service';
-import {LoadingStatus} from '../../../services/change/change-model';
+import {LoadingStatus} from '../../../models/change/change-model';
 import {DisplayLine} from '../../../api/diff';
 import {GrDownloadDialog} from '../../change/gr-download-dialog/gr-download-dialog';
 import {browserModelToken} from '../../../models/browser/browser-model';
 import {commentsModelToken} from '../../../models/comments/comments-model';
+import {changeModelToken} from '../../../models/change/change-model';
 import {resolve, DIPolymerElement} from '../../../models/dependency';
 import {BehaviorSubject} from 'rxjs';
 
@@ -358,7 +359,7 @@
   readonly userModel = getAppContext().userModel;
 
   // Private but used in tests.
-  readonly changeModel = getAppContext().changeModel;
+  readonly getChangeModel = resolve(this, changeModelToken);
 
   // Private but used in tests.
   readonly getBrowserModel = resolve(this, browserModelToken);
@@ -408,7 +409,7 @@
       })
     );
     this.subscriptions.push(
-      this.changeModel.change$.subscribe(change => {
+      this.getChangeModel().change$.subscribe(change => {
         // The diff view is tied to a specfic change number, so don't update
         // _change to undefined.
         if (change) this._change = change;
@@ -416,19 +417,19 @@
     );
 
     this.subscriptions.push(
-      this.changeModel.reviewedFiles$.subscribe(reviewedFiles => {
+      this.getChangeModel().reviewedFiles$.subscribe(reviewedFiles => {
         this.reviewedFiles = new Set(reviewedFiles) ?? new Set();
       })
     );
 
     this.subscriptions.push(
-      this.changeModel.diffPath$.subscribe(path => (this._path = path))
+      this.getChangeModel().diffPath$.subscribe(path => (this._path = path))
     );
 
     this.subscriptions.push(
       combineLatest(
-        this.changeModel.diffPath$,
-        this.changeModel.reviewedFiles$
+        this.getChangeModel().diffPath$,
+        this.getChangeModel().reviewedFiles$
       ).subscribe(([path, files]) => {
         this.$.reviewed.checked = !!path && !!files && files.includes(path);
       })
@@ -439,15 +440,15 @@
     // properties since the method will be called anytime a property updates
     // but we only want to call this on the initial load.
     this.subscriptions.push(
-      this.changeModel.diffPath$
-        .pipe(
+      this.getChangeModel()
+        .diffPath$.pipe(
           filter(diffPath => !!diffPath),
           switchMap(() =>
             combineLatest(
-              this.changeModel.currentPatchNum$,
+              this.getChangeModel().currentPatchNum$,
               this.routerModel.routerView$,
               this.userModel.diffPreferences$,
-              this.changeModel.reviewedFiles$
+              this.getChangeModel().reviewedFiles$
             ).pipe(
               filter(
                 ([currentPatchNum, routerView, diffPrefs, reviewedFiles]) =>
@@ -465,7 +466,7 @@
         })
     );
     this.subscriptions.push(
-      this.changeModel.diffPath$.subscribe(path => (this._path = path))
+      this.getChangeModel().diffPath$.subscribe(path => (this._path = path))
     );
     this.addEventListener('open-fix-preview', e => this._onOpenFixPreview(e));
     this.cursor.replaceDiffs([this.$.diffHost]);
@@ -612,7 +613,7 @@
     const path = this._path;
     // if file is already reviewed then do not make a saveReview request
     if (this.reviewedFiles.has(path) && reviewed) return;
-    this.changeModel.setReviewedFilesStatus(
+    this.getChangeModel().setReviewedFilesStatus(
       this._changeNum,
       patchNum,
       path,
@@ -1037,7 +1038,7 @@
         GerritNav.navigateToChange(this._change);
         return;
       }
-      this.changeModel.updatePath(comment.path);
+      this.getChangeModel().updatePath(comment.path);
 
       const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
       if (!latestPatchNum) throw new Error('Missing _allPatchSets');
@@ -1047,7 +1048,7 @@
       this._focusLineNum = comment.line;
     } else {
       if (this.params.path) {
-        this.changeModel.updatePath(this.params.path);
+        this.getChangeModel().updatePath(this.params.path);
       }
       if (this.params.patchNum) {
         this._patchRange = {
@@ -1113,7 +1114,7 @@
     }
 
     this._files = {sortedFileList: [], changeFilesByPath: {}};
-    this.changeModel.updatePath(undefined);
+    this.getChangeModel().updatePath(undefined);
     this._patchRange = undefined;
     this._commitRange = undefined;
     this._focusLineNum = undefined;
@@ -1140,7 +1141,7 @@
     if (!this._change) {
       promises.push(
         until(
-          this.changeModel.changeLoadingStatus$,
+          this.getChangeModel().changeLoadingStatus$,
           status => status === LoadingStatus.LOADED
         )
       );
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
index 77bbded..3945941 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
@@ -19,7 +19,7 @@
 import './gr-diff-view.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 import {ChangeStatus, DiffViewMode, createDefaultDiffPrefs} from '../../../constants/constants.js';
-import {stubRestApi, stubUsers, waitUntil, stubChange} from '../../../test/test-utils.js';
+import {stubRestApi, stubUsers, waitUntil} from '../../../test/test-utils.js';
 import {ChangeComments} from '../gr-comment-api/gr-comment-api.js';
 import {GerritView} from '../../../services/router/router-model.js';
 import {
@@ -137,7 +137,7 @@
         sinon.stub(element.reporting, 'diffViewDisplayed');
         sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
         sinon.spy(element, '_paramsChanged');
-        element.changeModel.setState({
+        element.getChangeModel().setState({
           change: {
             ...createChange(),
             revisions: createRevisions(11),
@@ -247,7 +247,7 @@
           sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
           sinon.stub(element, '_isFileUnchanged').returns(true);
           sinon.spy(element, '_paramsChanged');
-          element.changeModel.setState({
+          element.getChangeModel().setState({
             change: {
               ...createChange(),
               revisions: createRevisions(11),
@@ -300,7 +300,7 @@
           sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
           sinon.stub(element, '_isFileUnchanged').returns(true);
           sinon.spy(element, '_paramsChanged');
-          element.changeModel.setState({
+          element.getChangeModel().setState({
             change: {
               ...createChange(),
               revisions: createRevisions(11),
@@ -382,7 +382,7 @@
       sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
       sinon.spy(element, '_paramsChanged');
       element._change = undefined;
-      element.changeModel.setState({
+      element.getChangeModel().setState({
         change: {
           ...createChange(),
           revisions: createRevisions(11),
@@ -1190,8 +1190,9 @@
 
     test('_prefs.manual_review true means set reviewed is not ' +
       'automatically called', async () => {
-      const setReviewedFileStatusStub = stubChange('setReviewedFilesStatus')
-          .callsFake(() => Promise.resolve());
+      const setReviewedFileStatusStub =
+        sinon.stub(element.getChangeModel(), 'setReviewedFilesStatus')
+            .callsFake(() => Promise.resolve());
 
       const setReviewedStatusStub = sinon.spy(element, 'setReviewedStatus');
 
@@ -1202,7 +1203,7 @@
         manual_review: true,
       };
       element.userModel.setDiffPreferences(diffPreferences);
-      element.changeModel.setState({
+      element.getChangeModel().setState({
         change: createChange(),
         diffPath: '/COMMIT_MSG',
         reviewedFiles: [],
@@ -1229,8 +1230,9 @@
 
     test('_prefs.manual_review false means set reviewed is called',
         async () => {
-          const setReviewedFileStatusStub = stubChange('setReviewedFilesStatus')
-              .callsFake(() => Promise.resolve());
+          const setReviewedFileStatusStub =
+              sinon.stub(element.getChangeModel(), 'setReviewedFilesStatus')
+                  .callsFake(() => Promise.resolve());
 
           sinon.stub(element.$.diffHost, 'reload');
           sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
@@ -1239,7 +1241,7 @@
             manual_review: false,
           };
           element.userModel.setDiffPreferences(diffPreferences);
-          element.changeModel.setState({
+          element.getChangeModel().setState({
             change: createChange(),
             diffPath: '/COMMIT_MSG',
             reviewedFiles: [],
@@ -1260,14 +1262,15 @@
         });
 
     test('file review status', async () => {
-      element.changeModel.setState({
+      element.getChangeModel().setState({
         change: createChange(),
         diffPath: '/COMMIT_MSG',
         reviewedFiles: [],
       });
       sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
-      const saveReviewedStub = stubChange('setReviewedFilesStatus')
-          .callsFake(() => Promise.resolve());
+      const saveReviewedStub =
+          sinon.stub(element.getChangeModel(), 'setReviewedFilesStatus')
+              .callsFake(() => Promise.resolve());
       sinon.stub(element.$.diffHost, 'reload');
 
       element.userModel.setDiffPreferences(createDefaultDiffPrefs());
@@ -1283,7 +1286,7 @@
 
       await waitUntil(() => saveReviewedStub.called);
 
-      element.changeModel.updateStateFileReviewed('/COMMIT_MSG', true);
+      element.getChangeModel().updateStateFileReviewed('/COMMIT_MSG', true);
       await flush();
 
       const reviewedStatusCheckBox = element.root.querySelector(
@@ -1298,7 +1301,7 @@
       assert.deepEqual(saveReviewedStub.lastCall.args,
           ['42', 2, '/COMMIT_MSG', false]);
 
-      element.changeModel.updateStateFileReviewed('/COMMIT_MSG', false);
+      element.getChangeModel().updateStateFileReviewed('/COMMIT_MSG', false);
       await flush();
 
       MockInteractions.tap(reviewedStatusCheckBox);
@@ -1317,7 +1320,8 @@
     });
 
     test('file review status with edit loaded', () => {
-      const saveReviewedStub = stubChange('setReviewedFilesStatus');
+      const saveReviewedStub =
+          sinon.stub(element.getChangeModel(), 'setReviewedFilesStatus');
 
       element._patchRange = {patchNum: EditPatchSetNum};
       flush();
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
index 151556c..7ed49de 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
@@ -72,6 +72,7 @@
 import {notDeepEqual} from '../../../utils/deep-util';
 import {resolve} from '../../../models/dependency';
 import {commentsModelToken} from '../../../models/comments/comments-model';
+import {changeModelToken} from '../../../models/change/change-model';
 import {whenRendered} from '../../../utils/dom-util';
 
 const NEWLINE_PATTERN = /\n/g;
@@ -251,7 +252,7 @@
   // Private but used in tests.
   readonly getCommentsModel = resolve(this, commentsModelToken);
 
-  private readonly changeModel = getAppContext().changeModel;
+  private readonly getChangeModel = resolve(this, changeModelToken);
 
   private readonly userModel = getAppContext().userModel;
 
@@ -261,9 +262,19 @@
 
   constructor() {
     super();
-    subscribe(this, this.changeModel.changeNum$, x => (this.changeNum = x));
+    this.shortcuts.addGlobal({key: 'e'}, () => this.handleExpandShortcut());
+    this.shortcuts.addGlobal({key: 'E'}, () => this.handleCollapseShortcut());
+  }
+
+  override connectedCallback(): void {
+    super.connectedCallback();
+    subscribe(
+      this,
+      this.getChangeModel().changeNum$,
+      x => (this.changeNum = x)
+    );
     subscribe(this, this.userModel.account$, x => (this.account = x));
-    subscribe(this, this.changeModel.repo$, x => (this.repoName = x));
+    subscribe(this, this.getChangeModel().repo$, x => (this.repoName = x));
     subscribe(this, this.userModel.diffPreferences$, x =>
       this.syntaxLayer.setEnabled(!!x.syntax_highlighting)
     );
@@ -282,8 +293,6 @@
         line_wrapping: true,
       };
     });
-    this.shortcuts.addGlobal({key: 'e'}, () => this.handleExpandShortcut());
-    this.shortcuts.addGlobal({key: 'E'}, () => this.handleCollapseShortcut());
   }
 
   static override get styles() {
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
index c8dab62..dc635db 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -67,6 +67,7 @@
 import {Subject} from 'rxjs';
 import {debounceTime} from 'rxjs/operators';
 import {configModelToken} from '../../../models/config/config-model';
+import {changeModelToken} from '../../../models/change/change-model';
 
 const UNSAVED_MESSAGE = 'Unable to save draft';
 
@@ -231,7 +232,7 @@
 
   private readonly reporting = getAppContext().reportingService;
 
-  private readonly changeModel = getAppContext().changeModel;
+  private readonly getChangeModel = resolve(this, changeModelToken);
 
   // Private but used in tests.
   readonly getCommentsModel = resolve(this, commentsModelToken);
@@ -262,18 +263,6 @@
 
   constructor() {
     super();
-    subscribe(this, this.userModel.account$, x => (this.account = x));
-    subscribe(this, this.userModel.isAdmin$, x => (this.isAdmin = x));
-
-    subscribe(this, this.changeModel.repo$, x => (this.repoName = x));
-    subscribe(this, this.changeModel.changeNum$, x => (this.changeNum = x));
-    subscribe(
-      this,
-      this.autoSaveTrigger$.pipe(debounceTime(AUTO_SAVE_DEBOUNCE_DELAY_MS)),
-      () => {
-        this.autoSave();
-      }
-    );
     this.shortcuts.addLocal({key: Key.ESC}, () => this.handleEsc());
     for (const key of ['s', Key.ENTER]) {
       for (const modifier of [Modifier.CTRL_KEY, Modifier.META_KEY]) {
@@ -291,6 +280,22 @@
       this.configModel().repoCommentLinks$,
       x => (this.commentLinks = x)
     );
+    subscribe(this, this.userModel.account$, x => (this.account = x));
+    subscribe(this, this.userModel.isAdmin$, x => (this.isAdmin = x));
+
+    subscribe(this, this.getChangeModel().repo$, x => (this.repoName = x));
+    subscribe(
+      this,
+      this.getChangeModel().changeNum$,
+      x => (this.changeNum = x)
+    );
+    subscribe(
+      this,
+      this.autoSaveTrigger$.pipe(debounceTime(AUTO_SAVE_DEBOUNCE_DELAY_MS)),
+      () => {
+        this.autoSave();
+      }
+    );
   }
 
   override disconnectedCallback() {
diff --git a/polygerrit-ui/app/embed/gr-diff-app-context-init.ts b/polygerrit-ui/app/embed/gr-diff-app-context-init.ts
index e2b74cd..53ed193 100644
--- a/polygerrit-ui/app/embed/gr-diff-app-context-init.ts
+++ b/polygerrit-ui/app/embed/gr-diff-app-context-init.ts
@@ -75,9 +75,6 @@
     restApiService: (_ctx: Partial<AppContext>) => {
       throw new Error('restApiService is not implemented');
     },
-    changeModel: (_ctx: Partial<AppContext>) => {
-      throw new Error('changeModel is not implemented');
-    },
     jsApiService: (_ctx: Partial<AppContext>) => {
       throw new Error('jsApiService is not implemented');
     },
diff --git a/polygerrit-ui/app/services/change/change-model.ts b/polygerrit-ui/app/models/change/change-model.ts
similarity index 96%
rename from polygerrit-ui/app/services/change/change-model.ts
rename to polygerrit-ui/app/models/change/change-model.ts
index 6bf7c103..6a93f33 100644
--- a/polygerrit-ui/app/services/change/change-model.ts
+++ b/polygerrit-ui/app/models/change/change-model.ts
@@ -38,7 +38,7 @@
   startWith,
   switchMap,
 } from 'rxjs/operators';
-import {RouterModel} from '../router/router-model';
+import {RouterModel} from '../../services/router/router-model';
 import {
   computeAllPatchSets,
   computeLatestPatchNum,
@@ -47,12 +47,13 @@
 import {fireAlert} from '../../utils/event-util';
 
 import {ChangeInfo} from '../../types/common';
-import {RestApiService} from '../gr-rest-api/gr-rest-api';
-import {Finalizable} from '../registry';
+import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
+import {Finalizable} from '../../services/registry';
 import {select} from '../../utils/observable-util';
 import {assertIsDefined} from '../../utils/common-util';
-import {Model} from '../../models/model';
-import {UserModel} from '../../models/user/user-model';
+import {Model} from '../model';
+import {UserModel} from '../user/user-model';
+import {define} from '../dependency';
 
 export enum LoadingStatus {
   NOT_LOADED = 'NOT_LOADED',
@@ -122,6 +123,8 @@
   loadingStatus: LoadingStatus.NOT_LOADED,
 };
 
+export const changeModelToken = define<ChangeModel>('change-model');
+
 export class ChangeModel extends Model<ChangeState> implements Finalizable {
   private change?: ParsedChangeInfo;
 
diff --git a/polygerrit-ui/app/services/change/change-model_test.ts b/polygerrit-ui/app/models/change/change-model_test.ts
similarity index 98%
rename from polygerrit-ui/app/services/change/change-model_test.ts
rename to polygerrit-ui/app/models/change/change-model_test.ts
index 37924c1..9a9034e 100644
--- a/polygerrit-ui/app/services/change/change-model_test.ts
+++ b/polygerrit-ui/app/models/change/change-model_test.ts
@@ -34,8 +34,8 @@
   PatchSetNum,
 } from '../../types/common';
 import {ParsedChangeInfo} from '../../types/types';
-import {getAppContext} from '../app-context';
-import {GerritView} from '../router/router-model';
+import {getAppContext} from '../../services/app-context';
+import {GerritView} from '../../services/router/router-model';
 import {ChangeState, LoadingStatus, updateChangeWithEdit} from './change-model';
 import {ChangeModel} from './change-model';
 
diff --git a/polygerrit-ui/app/models/checks/checks-model.ts b/polygerrit-ui/app/models/checks/checks-model.ts
index 10cb442..42af98a 100644
--- a/polygerrit-ui/app/models/checks/checks-model.ts
+++ b/polygerrit-ui/app/models/checks/checks-model.ts
@@ -47,7 +47,7 @@
   FetchResponse,
   ResponseCode,
 } from '../../api/checks';
-import {ChangeModel} from '../../services/change/change-model';
+import {ChangeModel} from '../change/change-model';
 import {ChangeInfo, NumericChangeId, PatchSetNumber} from '../../types/common';
 import {getCurrentRevision} from '../../utils/change-util';
 import {getShaByPatchNum} from '../../utils/patch-set-util';
diff --git a/polygerrit-ui/app/models/checks/checks-model_test.ts b/polygerrit-ui/app/models/checks/checks-model_test.ts
index e1d448e..a63a13b 100644
--- a/polygerrit-ui/app/models/checks/checks-model_test.ts
+++ b/polygerrit-ui/app/models/checks/checks-model_test.ts
@@ -29,6 +29,7 @@
 import {createParsedChange} from '../../test/test-data-generators';
 import {waitUntil, waitUntilCalled} from '../../test/test-utils';
 import {ParsedChangeInfo} from '../../types/types';
+import {changeModelToken} from '../change/change-model';
 
 const PLUGIN_NAME = 'test-plugin';
 
@@ -71,7 +72,7 @@
   setup(() => {
     model = new ChecksModel(
       getAppContext().routerModel,
-      getAppContext().changeModel,
+      testResolver(changeModelToken),
       getAppContext().reportingService,
       getAppContext().pluginsModel
     );
diff --git a/polygerrit-ui/app/models/comments/comments-model.ts b/polygerrit-ui/app/models/comments/comments-model.ts
index a722e14..03b71a4 100644
--- a/polygerrit-ui/app/models/comments/comments-model.ts
+++ b/polygerrit-ui/app/models/comments/comments-model.ts
@@ -43,7 +43,7 @@
 import {fire, fireAlert, fireEvent} from '../../utils/event-util';
 import {CURRENT} from '../../utils/patch-set-util';
 import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
-import {ChangeModel} from '../../services/change/change-model';
+import {ChangeModel} from '../change/change-model';
 import {Interaction, Timing} from '../../constants/reporting';
 import {assertIsDefined} from '../../utils/common-util';
 import {debounce, DelayedTask} from '../../utils/async-util';
diff --git a/polygerrit-ui/app/models/comments/comments-model_test.ts b/polygerrit-ui/app/models/comments/comments-model_test.ts
index e713893..9674794 100644
--- a/polygerrit-ui/app/models/comments/comments-model_test.ts
+++ b/polygerrit-ui/app/models/comments/comments-model_test.ts
@@ -32,6 +32,7 @@
 import {getAppContext} from '../../services/app-context';
 import {GerritView} from '../../services/router/router-model';
 import {PathToCommentsInfoMap} from '../../types/common';
+import {changeModelToken} from '../change/change-model';
 
 suite('comments model tests', () => {
   test('updateStateDeleteDraft', () => {
@@ -74,7 +75,7 @@
   test('loads comments', async () => {
     const model = new CommentsModel(
       getAppContext().routerModel,
-      getAppContext().changeModel,
+      testResolver(changeModelToken),
       getAppContext().restApiService,
       getAppContext().reportingService
     );
diff --git a/polygerrit-ui/app/models/config/config-model.ts b/polygerrit-ui/app/models/config/config-model.ts
index e57a724..2fd8a41 100644
--- a/polygerrit-ui/app/models/config/config-model.ts
+++ b/polygerrit-ui/app/models/config/config-model.ts
@@ -19,7 +19,7 @@
 import {switchMap} from 'rxjs/operators';
 import {Finalizable} from '../../services/registry';
 import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
-import {ChangeModel} from '../../services/change/change-model';
+import {ChangeModel} from '../change/change-model';
 import {select} from '../../utils/observable-util';
 import {Model} from '../model';
 import {define} from '../dependency';
diff --git a/polygerrit-ui/app/services/app-context-init.ts b/polygerrit-ui/app/services/app-context-init.ts
index ab86dbc..f24e709 100644
--- a/polygerrit-ui/app/services/app-context-init.ts
+++ b/polygerrit-ui/app/services/app-context-init.ts
@@ -22,7 +22,7 @@
 import {EventEmitter} from './gr-event-interface/gr-event-interface_impl';
 import {Auth} from './gr-auth/gr-auth_impl';
 import {GrRestApiServiceImpl} from '../elements/shared/gr-rest-api-interface/gr-rest-api-impl';
-import {ChangeModel} from './change/change-model';
+import {ChangeModel, changeModelToken} from '../models/change/change-model';
 import {ChecksModel, checksModelToken} from '../models/checks/checks-model';
 import {GrJsApiInterface} from '../elements/shared/gr-js-api-interface/gr-js-api-interface-element';
 import {GrStorageService} from './storage/gr-storage_impl';
@@ -60,15 +60,6 @@
       assertIsDefined(ctx.flagsService, 'flagsService)');
       return new GrRestApiServiceImpl(ctx.authService!, ctx.flagsService!);
     },
-    changeModel: (ctx: Partial<AppContext>) => {
-      const routerModel = ctx.routerModel;
-      const restApiService = ctx.restApiService;
-      const userModel = ctx.userModel;
-      assertIsDefined(routerModel, 'routerModel');
-      assertIsDefined(restApiService, 'restApiService');
-      assertIsDefined(userModel, 'userModel');
-      return new ChangeModel(routerModel, restApiService, userModel);
-    },
     jsApiService: (ctx: Partial<AppContext>) => {
       const reportingService = ctx.reportingService;
       assertIsDefined(reportingService, 'reportingService');
@@ -96,23 +87,27 @@
   const browserModel = new BrowserModel(appContext.userModel!);
   dependencies.set(browserModelToken, browserModel);
 
+  const changeModel = new ChangeModel(
+    appContext.routerModel,
+    appContext.restApiService,
+    appContext.userModel
+  );
+  dependencies.set(changeModelToken, changeModel);
+
   const commentsModel = new CommentsModel(
     appContext.routerModel,
-    appContext.changeModel,
+    changeModel,
     appContext.restApiService,
     appContext.reportingService
   );
   dependencies.set(commentsModelToken, commentsModel);
 
-  const configModel = new ConfigModel(
-    appContext.changeModel,
-    appContext.restApiService
-  );
+  const configModel = new ConfigModel(changeModel, appContext.restApiService);
   dependencies.set(configModelToken, configModel);
 
   const checksModel = new ChecksModel(
     appContext.routerModel,
-    appContext.changeModel,
+    changeModel,
     appContext.reportingService,
     appContext.pluginsModel
   );
diff --git a/polygerrit-ui/app/services/app-context.ts b/polygerrit-ui/app/services/app-context.ts
index 15f547f..965146a 100644
--- a/polygerrit-ui/app/services/app-context.ts
+++ b/polygerrit-ui/app/services/app-context.ts
@@ -20,7 +20,6 @@
 import {ReportingService} from './gr-reporting/gr-reporting';
 import {AuthService} from './gr-auth/gr-auth';
 import {RestApiService} from './gr-rest-api/gr-rest-api';
-import {ChangeModel} from './change/change-model';
 import {JsApiService} from '../elements/shared/gr-js-api-interface/gr-js-api-types';
 import {StorageService} from './storage/gr-storage';
 import {UserModel} from '../models/user/user-model';
@@ -35,7 +34,6 @@
   eventEmitter: EventEmitterService;
   authService: AuthService;
   restApiService: RestApiService;
-  changeModel: ChangeModel;
   jsApiService: JsApiService;
   storageService: StorageService;
   userModel: UserModel;
diff --git a/polygerrit-ui/app/test/common-test-setup-karma.ts b/polygerrit-ui/app/test/common-test-setup-karma.ts
index 888ace0..5c84b05 100644
--- a/polygerrit-ui/app/test/common-test-setup-karma.ts
+++ b/polygerrit-ui/app/test/common-test-setup-karma.ts
@@ -14,7 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import './common-test-setup';
+import {testResolver as testResolverImpl} from './common-test-setup';
 import '@polymer/test-fixture/test-fixture';
 import 'chai/chai';
 
@@ -23,10 +23,12 @@
     flush: typeof flushImpl;
     fixtureFromTemplate: typeof fixtureFromTemplateImpl;
     fixtureFromElement: typeof fixtureFromElementImpl;
+    testResolver: typeof testResolverImpl;
   }
   let flush: typeof flushImpl;
   let fixtureFromTemplate: typeof fixtureFromTemplateImpl;
   let fixtureFromElement: typeof fixtureFromElementImpl;
+  let testResolver: typeof testResolverImpl;
 }
 
 // Workaround for https://github.com/karma-runner/karma-mocha/issues/227
@@ -207,3 +209,4 @@
 
 window.fixtureFromTemplate = fixtureFromTemplateImpl;
 window.fixtureFromElement = fixtureFromElementImpl;
+window.testResolver = testResolverImpl;
diff --git a/polygerrit-ui/app/test/common-test-setup.ts b/polygerrit-ui/app/test/common-test-setup.ts
index 23c4141..571c227 100644
--- a/polygerrit-ui/app/test/common-test-setup.ts
+++ b/polygerrit-ui/app/test/common-test-setup.ts
@@ -126,18 +126,19 @@
   });
 }
 
-function resolveDependency(evt: DependencyRequestEvent<unknown>) {
-  const provider = injectedDependencies.get(evt.dependency);
+export function testResolver<T>(token: DependencyToken<T>): T {
+  const provider = injectedDependencies.get(token);
   if (provider) {
-    evt.callback(provider());
+    return provider() as T;
   } else {
-    throw new DependencyError(
-      evt.dependency,
-      'Forgot to set up dependency for tests'
-    );
+    throw new DependencyError(token, 'Forgot to set up dependency for tests');
   }
 }
 
+function resolveDependency(evt: DependencyRequestEvent<unknown>) {
+  evt.callback(testResolver(evt.dependency));
+}
+
 setup(() => {
   testSetupTimestampMs = new Date().getTime();
   addIronOverlayBackdropStyleEl();
@@ -148,7 +149,7 @@
   appContext = createTestAppContext();
   injectAppContext(appContext);
   finalizers.push(appContext);
-  const dependencies = createTestDependencies(appContext);
+  const dependencies = createTestDependencies(appContext, testResolver);
   for (const [token, provider] of dependencies) {
     injectDependency(token, provider);
   }
diff --git a/polygerrit-ui/app/test/test-app-context-init.ts b/polygerrit-ui/app/test/test-app-context-init.ts
index 6f3c18d..9608239 100644
--- a/polygerrit-ui/app/test/test-app-context-init.ts
+++ b/polygerrit-ui/app/test/test-app-context-init.ts
@@ -26,7 +26,7 @@
 import {GrAuthMock} from '../services/gr-auth/gr-auth_mock';
 import {FlagsServiceImplementation} from '../services/flags/flags_impl';
 import {EventEmitter} from '../services/gr-event-interface/gr-event-interface_impl';
-import {ChangeModel} from '../services/change/change-model';
+import {ChangeModel, changeModelToken} from '../models/change/change-model';
 import {ChecksModel, checksModelToken} from '../models/checks/checks-model';
 import {GrJsApiInterface} from '../elements/shared/gr-js-api-interface/gr-js-api-interface-element';
 import {UserModel} from '../models/user/user-model';
@@ -52,15 +52,6 @@
       return new GrAuthMock(ctx.eventEmitter);
     },
     restApiService: (_ctx: Partial<AppContext>) => grRestApiMock,
-    changeModel: (ctx: Partial<AppContext>) => {
-      const routerModel = ctx.routerModel;
-      const restApiService = ctx.restApiService;
-      const userModel = ctx.userModel;
-      assertIsDefined(routerModel, 'routerModel');
-      assertIsDefined(restApiService, 'restApiService');
-      assertIsDefined(userModel, 'userModel');
-      return new ChangeModel(routerModel, restApiService, userModel);
-    },
     jsApiService: (ctx: Partial<AppContext>) => {
       assertIsDefined(ctx.reportingService, 'reportingService');
       return new GrJsApiInterface(ctx.reportingService!);
@@ -87,34 +78,43 @@
 // change-model in change-model_test.ts because it creates one in the test
 // after setting up stubs.
 export function createTestDependencies(
-  appContext: AppContext
+  appContext: AppContext,
+  resolver: <T>(token: DependencyToken<T>) => T
 ): Map<DependencyToken<unknown>, Creator<unknown>> {
   const dependencies = new Map();
   const browserModel = () => new BrowserModel(appContext.userModel!);
   dependencies.set(browserModelToken, browserModel);
 
-  const commentsModel = () =>
+  const changeModelCreator = () =>
+    new ChangeModel(
+      appContext.routerModel,
+      appContext.restApiService,
+      appContext.userModel
+    );
+  dependencies.set(changeModelToken, changeModelCreator);
+
+  const commentsModelCreator = () =>
     new CommentsModel(
       appContext.routerModel,
-      appContext.changeModel,
+      resolver(changeModelToken),
       appContext.restApiService,
       appContext.reportingService
     );
-  dependencies.set(commentsModelToken, commentsModel);
+  dependencies.set(commentsModelToken, commentsModelCreator);
 
-  const configModel = () =>
-    new ConfigModel(appContext.changeModel, appContext.restApiService);
-  dependencies.set(configModelToken, configModel);
+  const configModelCreator = () =>
+    new ConfigModel(resolver(changeModelToken), appContext.restApiService);
+  dependencies.set(configModelToken, configModelCreator);
 
-  const checksModel = () =>
+  const checksModelCreator = () =>
     new ChecksModel(
       appContext.routerModel,
-      appContext.changeModel,
+      resolver(changeModelToken),
       appContext.reportingService,
       appContext.pluginsModel
     );
 
-  dependencies.set(checksModelToken, checksModel);
+  dependencies.set(checksModelToken, checksModelCreator);
 
   return dependencies;
 }
diff --git a/polygerrit-ui/app/test/test-utils.ts b/polygerrit-ui/app/test/test-utils.ts
index 88167e0..fbc2433 100644
--- a/polygerrit-ui/app/test/test-utils.ts
+++ b/polygerrit-ui/app/test/test-utils.ts
@@ -28,7 +28,6 @@
 import {queryAndAssert, query} from '../utils/common-util';
 import {FlagsService} from '../services/flags/flags';
 import {Key, Modifier} from '../utils/dom-util';
-import {ChangeModel} from '../services/change/change-model';
 export {query, queryAll, queryAndAssert} from '../utils/common-util';
 
 export interface MockPromise<T> extends Promise<T> {
@@ -112,10 +111,6 @@
   return sinon.spy(getAppContext().restApiService, method);
 }
 
-export function stubChange<K extends keyof ChangeModel>(method: K) {
-  return sinon.stub(getAppContext().changeModel, method);
-}
-
 export function stubUsers<K extends keyof UserModel>(method: K) {
   return sinon.stub(getAppContext().userModel, method);
 }