Merge "Move border-radius for gr-account-label to a css variable"
diff --git a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index dfb7a55..9d79855 100644
--- a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -1213,11 +1213,15 @@
       String ref,
       boolean exclusive,
       String... permissionNames) {
-    ProjectConfig cfg = projectCache.get(project).orElseThrow(illegalState(project)).getConfig();
-    AccessSection accessSection = cfg.getAccessSection(ref);
-    assertThat(accessSection).isNotNull();
+    Optional<AccessSection> accessSection =
+        projectCache
+            .get(project)
+            .orElseThrow(illegalState(project))
+            .getConfig()
+            .getAccessSection(ref);
+    assertThat(accessSection).isPresent();
     for (String permissionName : permissionNames) {
-      Permission permission = accessSection.getPermission(permissionName);
+      Permission permission = accessSection.get().getPermission(permissionName);
       assertPermission(permission, permissionName, exclusive, null);
       assertPermissionRule(
           permission.getRule(groupReference), groupReference, Action.ALLOW, false, 0, 0);
diff --git a/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java
index 89269b6..a3ddd98 100644
--- a/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java
@@ -151,15 +151,19 @@
         ProjectConfig projectConfig,
         ImmutableList<TestProjectUpdate.TestPermissionKey> removedPermissions) {
       for (TestProjectUpdate.TestPermissionKey p : removedPermissions) {
-        Permission permission =
-            projectConfig.getAccessSection(p.section(), true).getPermission(p.name(), true);
-        if (p.group().isPresent()) {
-          GroupReference group = GroupReference.create(p.group().get(), p.group().get().get());
-          group = projectConfig.resolve(group);
-          permission.removeRule(group);
-        } else {
-          permission.clearRules();
-        }
+        projectConfig.upsertAccessSection(
+            p.section(),
+            as -> {
+              Permission.Builder permission = as.upsertPermission(p.name());
+              if (p.group().isPresent()) {
+                GroupReference group =
+                    GroupReference.create(p.group().get(), p.group().get().get());
+                group = projectConfig.resolve(group);
+                permission.removeRule(group);
+              } else {
+                permission.clearRules();
+              }
+            });
       }
     }
 
@@ -168,10 +172,8 @@
       for (TestCapability c : addedCapabilities) {
         PermissionRule.Builder rule = newRule(projectConfig, c.group());
         rule.setRange(c.min(), c.max());
-        projectConfig
-            .getAccessSection(AccessSection.GLOBAL_CAPABILITIES, true)
-            .getPermission(c.name(), true)
-            .add(rule.build());
+        projectConfig.upsertAccessSection(
+            AccessSection.GLOBAL_CAPABILITIES, as -> as.upsertPermission(c.name()).add(rule));
       }
     }
 
@@ -181,10 +183,7 @@
         PermissionRule.Builder rule = newRule(projectConfig, p.group());
         rule.setAction(p.action());
         rule.setForce(p.force());
-        projectConfig
-            .getAccessSection(p.ref(), true)
-            .getPermission(p.name(), true)
-            .add(rule.build());
+        projectConfig.upsertAccessSection(p.ref(), as -> as.upsertPermission(p.name()).add(rule));
       }
     }
 
@@ -196,9 +195,8 @@
         rule.setRange(p.min(), p.max());
         String permissionName =
             p.impersonation() ? Permission.forLabelAs(p.name()) : Permission.forLabel(p.name());
-        Permission permission =
-            projectConfig.getAccessSection(p.ref(), true).getPermission(permissionName, true);
-        permission.add(rule.build());
+        projectConfig.upsertAccessSection(
+            p.ref(), as -> as.upsertPermission(permissionName).add(rule));
       }
     }
 
@@ -207,10 +205,9 @@
         ImmutableMap<TestProjectUpdate.TestPermissionKey, Boolean> exclusiveGroupPermissions) {
       exclusiveGroupPermissions.forEach(
           (key, exclusive) ->
-              projectConfig
-                  .getAccessSection(key.section(), true)
-                  .getPermission(key.name(), true)
-                  .setExclusiveGroup(exclusive));
+              projectConfig.upsertAccessSection(
+                  key.section(),
+                  as -> as.upsertPermission(key.name()).setExclusiveGroup(exclusive)));
     }
 
     private RevCommit headOrNull(String branch) {
diff --git a/java/com/google/gerrit/common/data/AccessSection.java b/java/com/google/gerrit/common/data/AccessSection.java
index 0c9663b..18682e3 100644
--- a/java/com/google/gerrit/common/data/AccessSection.java
+++ b/java/com/google/gerrit/common/data/AccessSection.java
@@ -14,18 +14,21 @@
 
 package com.google.gerrit.common.data;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static java.util.Objects.requireNonNull;
 
+import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Project;
 import java.util.ArrayList;
-import java.util.HashSet;
 import java.util.List;
-import java.util.Set;
+import java.util.Optional;
+import java.util.function.Consumer;
 
 /** Portion of a {@link Project} describing access rules. */
-public final class AccessSection implements Comparable<AccessSection> {
+@AutoValue
+public abstract class AccessSection implements Comparable<AccessSection> {
   /** Special name given to the global capabilities; not a valid reference. */
   public static final String GLOBAL_CAPABILITIES = "GLOBAL_CAPABILITIES";
   /** Pattern that matches all references in a project. */
@@ -38,12 +41,16 @@
   public static final String REGEX_PREFIX = "^";
 
   /** Name of the access section. It could be a ref pattern or something else. */
-  private String name;
+  public abstract String getName();
 
-  private List<Permission> permissions;
+  public abstract ImmutableList<Permission> getPermissions();
 
-  public AccessSection(String name) {
-    this.name = name;
+  public static AccessSection create(String name) {
+    return builder(name).build();
+  }
+
+  public static Builder builder(String name) {
+    return new AutoValue_AccessSection.Builder().setName(name).setPermissions(ImmutableList.of());
   }
 
   /** @return true if the name is likely to be a valid reference section name. */
@@ -51,101 +58,19 @@
     return name.startsWith("refs/") || name.startsWith("^refs/");
   }
 
-  public String getName() {
-    return name;
-  }
-
-  public ImmutableList<Permission> getPermissions() {
-    return permissions == null ? ImmutableList.of() : ImmutableList.copyOf(permissions);
-  }
-
-  public void setPermissions(List<Permission> list) {
-    requireNonNull(list);
-
-    Set<String> names = new HashSet<>();
-    for (Permission p : list) {
-      if (!names.add(p.getName().toLowerCase())) {
-        throw new IllegalArgumentException();
-      }
-    }
-
-    permissions = new ArrayList<>(list);
-  }
-
   @Nullable
   public Permission getPermission(String name) {
-    return getPermission(name, false);
-  }
-
-  @Nullable
-  public Permission getPermission(String name, boolean create) {
     requireNonNull(name);
-
-    if (permissions != null) {
-      for (Permission p : permissions) {
-        if (p.getName().equalsIgnoreCase(name)) {
-          return p;
-        }
+    for (Permission p : getPermissions()) {
+      if (p.getName().equalsIgnoreCase(name)) {
+        return p;
       }
     }
-
-    if (create) {
-      if (permissions == null) {
-        permissions = new ArrayList<>();
-      }
-
-      Permission p = new Permission(name);
-      permissions.add(p);
-      return p;
-    }
-
     return null;
   }
 
-  public void addPermission(Permission permission) {
-    requireNonNull(permission);
-
-    if (permissions == null) {
-      permissions = new ArrayList<>();
-    }
-
-    for (Permission p : permissions) {
-      if (p.getName().equalsIgnoreCase(permission.getName())) {
-        throw new IllegalArgumentException();
-      }
-    }
-
-    permissions.add(permission);
-  }
-
-  public void remove(Permission permission) {
-    requireNonNull(permission);
-    removePermission(permission.getName());
-  }
-
-  public void removePermission(String name) {
-    requireNonNull(name);
-
-    if (permissions != null) {
-      permissions.removeIf(permission -> name.equalsIgnoreCase(permission.getName()));
-    }
-  }
-
-  public void mergeFrom(AccessSection section) {
-    requireNonNull(section);
-
-    for (Permission src : section.getPermissions()) {
-      Permission dst = getPermission(src.getName());
-      if (dst != null) {
-        dst.mergeFrom(src);
-      } else {
-        permissions.add(src);
-      }
-    }
-  }
-
   @Override
-  public int compareTo(AccessSection o) {
+  public final int compareTo(AccessSection o) {
     return comparePattern().compareTo(o.comparePattern());
   }
 
@@ -157,33 +82,85 @@
   }
 
   @Override
-  public String toString() {
+  public final String toString() {
     return "AccessSection[" + getName() + "]";
   }
 
-  @Override
-  public boolean equals(Object obj) {
-    if (!(obj instanceof AccessSection)) {
-      return false;
-    }
-
-    AccessSection other = (AccessSection) obj;
-    if (!getName().equals(other.getName())) {
-      return false;
-    }
-    return new HashSet<>(getPermissions())
-        .equals(new HashSet<>(((AccessSection) obj).getPermissions()));
+  public Builder toBuilder() {
+    Builder b = autoToBuilder();
+    b.getPermissions().stream().map(Permission::toBuilder).forEach(p -> b.addPermission(p));
+    return b;
   }
 
-  @Override
-  public int hashCode() {
-    int hashCode = super.hashCode();
-    if (permissions != null) {
-      for (Permission permission : permissions) {
-        hashCode += permission.hashCode();
-      }
+  protected abstract Builder autoToBuilder();
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    private final List<Permission.Builder> permissionBuilders;
+
+    public Builder() {
+      permissionBuilders = new ArrayList<>();
     }
-    hashCode += getName().hashCode();
-    return hashCode;
+
+    public abstract Builder setName(String name);
+
+    public abstract String getName();
+
+    public Builder modifyPermissions(Consumer<List<Permission.Builder>> modification) {
+      modification.accept(permissionBuilders);
+      return this;
+    }
+
+    public Builder addPermission(Permission.Builder permission) {
+      requireNonNull(permission, "permission must be non-null");
+      return modifyPermissions(p -> p.add(permission));
+    }
+
+    public Builder remove(Permission.Builder permission) {
+      requireNonNull(permission, "permission must be non-null");
+      return removePermission(permission.getName());
+    }
+
+    public Builder removePermission(String name) {
+      requireNonNull(name, "name must be non-null");
+      return modifyPermissions(
+          p -> p.removeIf(permissionBuilder -> name.equalsIgnoreCase(permissionBuilder.getName())));
+    }
+
+    public Permission.Builder upsertPermission(String permissionName) {
+      requireNonNull(permissionName, "permissionName must be non-null");
+
+      Optional<Permission.Builder> maybePermission =
+          permissionBuilders.stream()
+              .filter(p -> p.getName().equalsIgnoreCase(permissionName))
+              .findAny();
+      if (maybePermission.isPresent()) {
+        return maybePermission.get();
+      }
+
+      Permission.Builder permission = Permission.builder(permissionName);
+      modifyPermissions(p -> p.add(permission));
+      return permission;
+    }
+
+    public AccessSection build() {
+      setPermissions(
+          permissionBuilders.stream().map(Permission.Builder::build).collect(toImmutableList()));
+      if (getPermissions().size()
+          > getPermissions().stream()
+              .map(Permission::getName)
+              .map(String::toLowerCase)
+              .distinct()
+              .count()) {
+        throw new IllegalArgumentException("duplicate permissions: " + getPermissions());
+      }
+      return autoBuild();
+    }
+
+    protected abstract AccessSection autoBuild();
+
+    protected abstract ImmutableList<Permission> getPermissions();
+
+    protected abstract Builder setPermissions(ImmutableList<Permission> permissions);
   }
 }
diff --git a/java/com/google/gerrit/common/data/LabelType.java b/java/com/google/gerrit/common/data/LabelType.java
index 9c1423d..3e58be7 100644
--- a/java/com/google/gerrit/common/data/LabelType.java
+++ b/java/com/google/gerrit/common/data/LabelType.java
@@ -259,6 +259,8 @@
 
     protected abstract String getName();
 
+    protected abstract ImmutableList<Short> getCopyValues();
+
     protected abstract Builder setByValue(ImmutableMap<Short, LabelValue> byValue);
 
     @Nullable
@@ -290,6 +292,8 @@
       }
       setByValue(byValue.build());
 
+      setCopyValues(ImmutableList.sortedCopyOf(getCopyValues()));
+
       return autoBuild();
     }
   }
diff --git a/java/com/google/gerrit/common/data/Permission.java b/java/com/google/gerrit/common/data/Permission.java
index 5aaffba..93155d5 100644
--- a/java/com/google/gerrit/common/data/Permission.java
+++ b/java/com/google/gerrit/common/data/Permission.java
@@ -14,14 +14,19 @@
 
 package com.google.gerrit.common.data;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
 import java.util.ArrayList;
-import java.util.HashSet;
 import java.util.Iterator;
 import java.util.List;
+import java.util.function.Consumer;
 
 /** A single permission within an {@link AccessSection} of a project. */
-public class Permission implements Comparable<Permission> {
+@AutoValue
+public abstract class Permission implements Comparable<Permission> {
   public static final String ABANDON = "abandon";
   public static final String ADD_PATCH_SET = "addPatchSet";
   public static final String CREATE = "create";
@@ -133,18 +138,21 @@
     return true;
   }
 
-  protected String name;
-  protected boolean exclusiveGroup;
-  protected List<PermissionRule> rules;
+  public abstract String getName();
 
-  protected Permission() {}
+  protected abstract boolean isExclusiveGroup();
 
-  public Permission(String name) {
-    this.name = name;
+  public abstract ImmutableList<PermissionRule> getRules();
+
+  public static Builder builder(String name) {
+    return new AutoValue_Permission.Builder()
+        .setName(name)
+        .setExclusiveGroup(false)
+        .setRules(ImmutableList.of());
   }
 
-  public String getName() {
-    return name;
+  public static Permission create(String name) {
+    return builder(name).build();
   }
 
   public String getLabel() {
@@ -155,97 +163,32 @@
     // Only permit exclusive group behavior on non OWNER permissions,
     // otherwise an owner might lose access to a delegated subspace.
     //
-    return exclusiveGroup && !OWNER.equals(getName());
+    return isExclusiveGroup() && !OWNER.equals(getName());
   }
 
-  public void setExclusiveGroup(boolean newExclusiveGroup) {
-    exclusiveGroup = newExclusiveGroup;
-  }
-
-  public ImmutableList<PermissionRule> getRules() {
-    return rules == null ? ImmutableList.of() : ImmutableList.copyOf(rules);
-  }
-
-  public void setRules(List<PermissionRule> list) {
-    rules = new ArrayList<>(list);
-  }
-
-  public void add(PermissionRule rule) {
-    initRules();
-    rules.add(rule);
-  }
-
-  public void remove(PermissionRule rule) {
-    if (rule != null) {
-      removeRule(rule.getGroup());
-    }
-  }
-
-  public void removeRule(GroupReference group) {
-    if (rules != null) {
-      rules.removeIf(permissionRule -> sameGroup(permissionRule, group));
-    }
-  }
-
-  public void clearRules() {
-    if (rules != null) {
-      rules.clear();
-    }
-  }
-
+  @Nullable
   public PermissionRule getRule(GroupReference group) {
-    return getRule(group, false);
-  }
-
-  public PermissionRule getRule(GroupReference group, boolean create) {
-    initRules();
-
-    for (PermissionRule r : rules) {
+    for (PermissionRule r : getRules()) {
       if (sameGroup(r, group)) {
         return r;
       }
     }
 
-    if (create) {
-      PermissionRule r = PermissionRule.create(group);
-      rules.add(r);
-      return r;
-    }
     return null;
   }
 
-  void mergeFrom(Permission src) {
-    for (PermissionRule srcRule : src.getRules()) {
-      PermissionRule dstRule = getRule(srcRule.getGroup());
-      if (dstRule != null) {
-        rules.remove(dstRule);
-        rules.add(PermissionRule.merge(srcRule, dstRule));
-      } else {
-        add(srcRule);
-      }
-    }
-  }
-
   private static boolean sameGroup(PermissionRule rule, GroupReference group) {
-    if (group.getUUID() != null) {
+    if (group.getUUID() != null && rule.getGroup().getUUID() != null) {
       return group.getUUID().equals(rule.getGroup().getUUID());
-
-    } else if (group.getName() != null) {
+    } else if (group.getName() != null && rule.getGroup().getName() != null) {
       return group.getName().equals(rule.getGroup().getName());
-
     } else {
       return false;
     }
   }
 
-  private void initRules() {
-    if (rules == null) {
-      rules = new ArrayList<>(4);
-    }
-  }
-
   @Override
-  public int compareTo(Permission b) {
+  public final int compareTo(Permission b) {
     int cmp = index(this) - index(b);
     if (cmp == 0) {
       cmp = getName().compareTo(b.getName());
@@ -265,28 +208,10 @@
   }
 
   @Override
-  public boolean equals(Object obj) {
-    if (!(obj instanceof Permission)) {
-      return false;
-    }
-
-    final Permission other = (Permission) obj;
-    if (!name.equals(other.name) || exclusiveGroup != other.exclusiveGroup) {
-      return false;
-    }
-    return new HashSet<>(getRules()).equals(new HashSet<>(other.getRules()));
-  }
-
-  @Override
-  public int hashCode() {
-    return name.hashCode();
-  }
-
-  @Override
-  public String toString() {
+  public final String toString() {
     StringBuilder bldr = new StringBuilder();
-    bldr.append(name).append(" ");
-    if (exclusiveGroup) {
+    bldr.append(getName()).append(" ");
+    if (isExclusiveGroup()) {
       bldr.append("[exclusive] ");
     }
     bldr.append("[");
@@ -300,4 +225,67 @@
     bldr.append("]");
     return bldr.toString();
   }
+
+  protected abstract Builder autoToBuilder();
+
+  public Builder toBuilder() {
+    Builder b = autoToBuilder();
+    getRules().stream().map(PermissionRule::toBuilder).forEach(r -> b.add(r));
+    return b;
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    private final List<PermissionRule.Builder> rulesBuilders;
+
+    Builder() {
+      rulesBuilders = new ArrayList<>();
+    }
+
+    public abstract Builder setName(String value);
+
+    public abstract String getName();
+
+    public abstract Builder setExclusiveGroup(boolean value);
+
+    public Builder modifyRules(Consumer<List<PermissionRule.Builder>> modification) {
+      modification.accept(rulesBuilders);
+      return this;
+    }
+
+    public Builder add(PermissionRule.Builder rule) {
+      return modifyRules(r -> r.add(rule));
+    }
+
+    public Builder remove(PermissionRule rule) {
+      if (rule != null) {
+        return removeRule(rule.getGroup());
+      }
+      return this;
+    }
+
+    public Builder removeRule(GroupReference group) {
+      return modifyRules(rules -> rules.removeIf(rule -> sameGroup(rule.build(), group)));
+    }
+
+    public Builder clearRules() {
+      return modifyRules(r -> r.clear());
+    }
+
+    public Permission build() {
+      setRules(
+          rulesBuilders.stream().map(PermissionRule.Builder::build).collect(toImmutableList()));
+      return autoBuild();
+    }
+
+    public List<PermissionRule.Builder> getRulesBuilders() {
+      return rulesBuilders;
+    }
+
+    protected abstract ImmutableList<PermissionRule> getRules();
+
+    protected abstract Builder setRules(ImmutableList<PermissionRule> rules);
+
+    protected abstract Permission autoBuild();
+  }
 }
diff --git a/java/com/google/gerrit/common/data/PermissionRule.java b/java/com/google/gerrit/common/data/PermissionRule.java
index c543769..c411652 100644
--- a/java/com/google/gerrit/common/data/PermissionRule.java
+++ b/java/com/google/gerrit/common/data/PermissionRule.java
@@ -263,6 +263,8 @@
 
     public abstract Builder setMax(int max);
 
+    public abstract GroupReference getGroup();
+
     public abstract PermissionRule build();
   }
 }
diff --git a/java/com/google/gerrit/entities/Project.java b/java/com/google/gerrit/entities/Project.java
index 759d50a..ef3cbeb 100644
--- a/java/com/google/gerrit/entities/Project.java
+++ b/java/com/google/gerrit/entities/Project.java
@@ -103,7 +103,7 @@
   @Nullable
   public abstract String getDescription();
 
-  protected abstract ImmutableMap<BooleanProjectConfig, InheritableBoolean> getBooleanConfigs();
+  public abstract ImmutableMap<BooleanProjectConfig, InheritableBoolean> getBooleanConfigs();
 
   /**
    * Submit type as configured in {@code project.config}.
diff --git a/java/com/google/gerrit/extensions/api/projects/ConfigInfo.java b/java/com/google/gerrit/extensions/api/projects/ConfigInfo.java
index fb2a0fe..3ba1277 100644
--- a/java/com/google/gerrit/extensions/api/projects/ConfigInfo.java
+++ b/java/com/google/gerrit/extensions/api/projects/ConfigInfo.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.extensions.api.projects;
 
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.ProjectState;
@@ -49,7 +51,7 @@
 
   public Map<String, CommentLinkInfo> commentlinks;
 
-  public Map<String, List<String>> extensionPanelNames;
+  public ImmutableMap<String, ImmutableList<String>> extensionPanelNames;
 
   public static class InheritedBooleanInfo {
     public Boolean value;
diff --git a/java/com/google/gerrit/server/CreateGroupPermissionSyncer.java b/java/com/google/gerrit/server/CreateGroupPermissionSyncer.java
index 17313e4..0e593b7 100644
--- a/java/com/google/gerrit/server/CreateGroupPermissionSyncer.java
+++ b/java/com/google/gerrit/server/CreateGroupPermissionSyncer.java
@@ -14,8 +14,6 @@
 
 package com.google.gerrit.server;
 
-import static java.util.stream.Collectors.toList;
-
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.data.AccessSection;
@@ -34,6 +32,7 @@
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.HashSet;
+import java.util.Optional;
 import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
@@ -85,10 +84,10 @@
         new HashSet<>(allProjectsState.getCapabilityCollection().createGroup);
     Set<PermissionRule> createGroupsRef = new HashSet<>();
 
-    AccessSection allUsersCreateGroupAccessSection =
+    Optional<AccessSection> allUsersCreateGroupAccessSection =
         allUsersState.getConfig().getAccessSection(RefNames.REFS_GROUPS + "*");
-    if (allUsersCreateGroupAccessSection != null) {
-      Permission create = allUsersCreateGroupAccessSection.getPermission(Permission.CREATE);
+    if (allUsersCreateGroupAccessSection.isPresent()) {
+      Permission create = allUsersCreateGroupAccessSection.get().getPermission(Permission.CREATE);
       if (create != null && create.getRules() != null) {
         createGroupsRef.addAll(create.getRules());
       }
@@ -101,23 +100,25 @@
 
     try (MetaDataUpdate md = metaDataUpdateFactory.get().create(allUsers)) {
       ProjectConfig config = projectConfigFactory.read(md);
-      AccessSection createGroupAccessSection =
-          config.getAccessSection(RefNames.REFS_GROUPS + "*", true);
-      if (createGroupsGlobal.isEmpty()) {
-        createGroupAccessSection.setPermissions(
-            createGroupAccessSection.getPermissions().stream()
-                .filter(p -> !Permission.CREATE.equals(p.getName()))
-                .collect(toList()));
-        config.replace(createGroupAccessSection);
-      } else {
-        // The create permission is managed by Gerrit at this point only so there is no concern of
-        // overwriting user-defined permissions here.
-        Permission createGroupPermission = new Permission(Permission.CREATE);
-        createGroupAccessSection.remove(createGroupPermission);
-        createGroupAccessSection.addPermission(createGroupPermission);
-        createGroupsGlobal.forEach(createGroupPermission::add);
-        config.replace(createGroupAccessSection);
-      }
+      config.upsertAccessSection(
+          RefNames.REFS_GROUPS + "*",
+          refsGroupsAccessSectionBuilder -> {
+            if (createGroupsGlobal.isEmpty()) {
+              refsGroupsAccessSectionBuilder.modifyPermissions(
+                  permissions -> {
+                    permissions.removeIf(p -> Permission.CREATE.equals(p.getName()));
+                  });
+            } else {
+              // The create permission is managed by Gerrit at this point only so there is no
+              // concern of overwriting user-defined permissions here.
+              Permission.Builder createGroupPermission = Permission.builder(Permission.CREATE);
+              refsGroupsAccessSectionBuilder.remove(createGroupPermission);
+              refsGroupsAccessSectionBuilder.addPermission(createGroupPermission);
+              createGroupsGlobal.stream()
+                  .map(p -> p.toBuilder())
+                  .forEach(createGroupPermission::add);
+            }
+          });
 
       config.commit(md);
       projectCache.evict(config.getProject());
diff --git a/java/com/google/gerrit/server/account/AccountManager.java b/java/com/google/gerrit/server/account/AccountManager.java
index 7a5b1aa..cb9412c 100644
--- a/java/com/google/gerrit/server/account/AccountManager.java
+++ b/java/com/google/gerrit/server/account/AccountManager.java
@@ -328,6 +328,7 @@
               .getAllProjects()
               .getConfig()
               .getAccessSection(AccessSection.GLOBAL_CAPABILITIES)
+              .orElseThrow(() -> new IllegalStateException("access section does not exist"))
               .getPermission(GlobalCapability.ADMINISTRATE_SERVER);
 
       AccountGroup.UUID adminGroupUuid = admin.getRules().get(0).getGroup().getUUID();
diff --git a/java/com/google/gerrit/server/account/CapabilityCollection.java b/java/com/google/gerrit/server/account/CapabilityCollection.java
index 157f946..de522ae 100644
--- a/java/com/google/gerrit/server/account/CapabilityCollection.java
+++ b/java/com/google/gerrit/server/account/CapabilityCollection.java
@@ -60,7 +60,7 @@
     this.systemGroupBackend = systemGroupBackend;
 
     if (section == null) {
-      section = new AccessSection(AccessSection.GLOBAL_CAPABILITIES);
+      section = AccessSection.create(AccessSection.GLOBAL_CAPABILITIES);
     }
 
     Map<String, List<PermissionRule>> tmp = new HashMap<>();
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/BUILD b/java/com/google/gerrit/server/cache/serialize/entities/BUILD
new file mode 100644
index 0000000..81f8f6b
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/serialize/entities/BUILD
@@ -0,0 +1,18 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
+java_library(
+    name = "entities",
+    srcs = glob(["*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/common:annotations",
+        "//java/com/google/gerrit/entities",
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/git",
+        "//java/com/google/gerrit/proto",
+        "//lib:guava",
+        "//lib:jgit",
+        "//lib:protobuf",
+        "//proto:cache_java_proto",
+    ],
+)
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/ProjectSerializer.java b/java/com/google/gerrit/server/cache/serialize/entities/ProjectSerializer.java
new file mode 100644
index 0000000..aa1f4ce
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/serialize/entities/ProjectSerializer.java
@@ -0,0 +1,95 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.serialize.entities;
+
+import static com.google.common.base.Strings.emptyToNull;
+import static com.google.common.base.Strings.nullToEmpty;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+
+import com.google.common.base.Converter;
+import com.google.common.base.Enums;
+import com.google.gerrit.entities.BooleanProjectConfig;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.gerrit.extensions.client.ProjectState;
+import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.server.cache.proto.Cache;
+import java.util.Arrays;
+import java.util.Set;
+
+/** Helper to (de)serialize values for caches. */
+public class ProjectSerializer {
+  private static final Converter<String, ProjectState> PROJECT_STATE_CONVERTER =
+      Enums.stringConverter(ProjectState.class);
+  private static final Converter<String, SubmitType> SUBMIT_TYPE_CONVERTER =
+      Enums.stringConverter(SubmitType.class);
+
+  public static Project deserialize(Cache.ProjectProto proto) {
+    Project.Builder builder =
+        Project.builder(Project.nameKey(proto.getName()))
+            .setSubmitType(SUBMIT_TYPE_CONVERTER.convert(proto.getSubmitType()))
+            .setState(PROJECT_STATE_CONVERTER.convert(proto.getState()))
+            .setDescription(emptyToNull(proto.getDescription()))
+            .setParent(emptyToNull(proto.getParent()))
+            .setMaxObjectSizeLimit(emptyToNull(proto.getMaxObjectSizeLimit()))
+            .setDefaultDashboard(emptyToNull(proto.getDefaultDashboard()))
+            .setLocalDefaultDashboard(emptyToNull(proto.getLocalDefaultDashboard()))
+            .setConfigRefState(emptyToNull(proto.getConfigRefState()));
+
+    Set<String> configs =
+        Arrays.stream(BooleanProjectConfig.values())
+            .map(BooleanProjectConfig::name)
+            .collect(toImmutableSet());
+    proto
+        .getBooleanConfigsMap()
+        .entrySet()
+        .forEach(
+            configEntry -> {
+              if (configs.contains(configEntry.getKey())) {
+                builder.setBooleanConfig(
+                    BooleanProjectConfig.valueOf(configEntry.getKey()),
+                    InheritableBoolean.valueOf(configEntry.getValue()));
+              }
+            });
+
+    return builder.build();
+  }
+
+  public static Cache.ProjectProto serialize(Project autoValue) {
+    Cache.ProjectProto.Builder builder =
+        Cache.ProjectProto.newBuilder()
+            .setName(autoValue.getName())
+            .setSubmitType(SUBMIT_TYPE_CONVERTER.reverse().convert(autoValue.getSubmitType()))
+            .setState(PROJECT_STATE_CONVERTER.reverse().convert(autoValue.getState()))
+            .setDescription(nullToEmpty(autoValue.getDescription()))
+            .setParent(nullToEmpty(autoValue.getParentName()))
+            .setMaxObjectSizeLimit(nullToEmpty(autoValue.getMaxObjectSizeLimit()))
+            .setDefaultDashboard(nullToEmpty(autoValue.getDefaultDashboard()))
+            .setLocalDefaultDashboard(nullToEmpty(autoValue.getLocalDefaultDashboard()))
+            .setConfigRefState(nullToEmpty(autoValue.getConfigRefState()));
+
+    autoValue
+        .getBooleanConfigs()
+        .entrySet()
+        .forEach(
+            configEntry -> {
+              builder.putBooleanConfigs(configEntry.getKey().name(), configEntry.getValue().name());
+            });
+
+    return builder.build();
+  }
+
+  private ProjectSerializer() {}
+}
diff --git a/java/com/google/gerrit/server/change/ChangeResource.java b/java/com/google/gerrit/server/change/ChangeResource.java
index a7fc5de..5a1798d 100644
--- a/java/com/google/gerrit/server/change/ChangeResource.java
+++ b/java/com/google/gerrit/server/change/ChangeResource.java
@@ -20,6 +20,7 @@
 import com.google.common.base.MoreObjects;
 import com.google.common.hash.Hasher;
 import com.google.common.hash.Hashing;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.Change;
@@ -188,7 +189,7 @@
     Iterable<ProjectState> projectStateTree =
         projectCache.get(getProject()).orElseThrow(illegalState(getProject())).tree();
     for (ProjectState p : projectStateTree) {
-      hashObjectId(h, p.getConfig().getRevision(), buf);
+      hashObjectId(h, p.getConfig().getRevision().orElse(null), buf);
     }
 
     changeETagComputation.runEach(
@@ -218,7 +219,7 @@
     }
   }
 
-  private void hashObjectId(Hasher h, ObjectId id, byte[] buf) {
+  private void hashObjectId(Hasher h, @Nullable ObjectId id, byte[] buf) {
     MoreObjects.firstNonNull(id, ObjectId.zeroId()).copyRawTo(buf, 0);
     h.putBytes(buf);
   }
diff --git a/java/com/google/gerrit/server/config/PluginConfig.java b/java/com/google/gerrit/server/config/PluginConfig.java
index 13c5442..f41d5c2 100644
--- a/java/com/google/gerrit/server/config/PluginConfig.java
+++ b/java/com/google/gerrit/server/config/PluginConfig.java
@@ -52,7 +52,7 @@
     ProjectState parent = Iterables.getFirst(state.parents(), null);
     if (parent != null) {
       PluginConfig parentPluginConfig =
-          parent.getConfig().getPluginConfig(pluginName).withInheritance(projectStateFactory);
+          parent.getBareConfig().getPluginConfig(pluginName).withInheritance(projectStateFactory);
       Set<String> allNames = cfg.getNames(PLUGIN, pluginName);
       cfg = copyConfig(cfg);
       for (String name : parentPluginConfig.cfg.getNames(PLUGIN, pluginName)) {
diff --git a/java/com/google/gerrit/server/config/PluginConfigFactory.java b/java/com/google/gerrit/server/config/PluginConfigFactory.java
index 483fc0a..4ba8ef6 100644
--- a/java/com/google/gerrit/server/config/PluginConfigFactory.java
+++ b/java/com/google/gerrit/server/config/PluginConfigFactory.java
@@ -150,7 +150,7 @@
    * @return the plugin configuration from the 'project.config' file of the specified project
    */
   public PluginConfig getFromProjectConfig(ProjectState projectState, String pluginName) {
-    return projectState.getConfig().getPluginConfig(pluginName);
+    return projectState.getBareConfig().getPluginConfig(pluginName);
   }
 
   /**
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
index 5436db7..3dc8961 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -1253,12 +1253,15 @@
       ProjectConfigEntry configEntry = e.getProvider().get();
       String value = pluginCfg.getString(e.getExportName());
       String oldValue =
-          projectState.getConfig().getPluginConfig(e.getPluginName()).getString(e.getExportName());
+          projectState
+              .getBareConfig()
+              .getPluginConfig(e.getPluginName())
+              .getString(e.getExportName());
       if (configEntry.getType() == ProjectConfigEntryType.ARRAY) {
         oldValue =
             Arrays.stream(
                     projectState
-                        .getConfig()
+                        .getBareConfig()
                         .getPluginConfig(e.getPluginName())
                         .getStringList(e.getExportName()))
                 .collect(joining("\n"));
diff --git a/java/com/google/gerrit/server/git/validators/MergeValidators.java b/java/com/google/gerrit/server/git/validators/MergeValidators.java
index 04cbe36..d1c873a 100644
--- a/java/com/google/gerrit/server/git/validators/MergeValidators.java
+++ b/java/com/google/gerrit/server/git/validators/MergeValidators.java
@@ -229,7 +229,7 @@
             String value = pluginCfg.getString(e.getExportName());
             String oldValue =
                 destProject
-                    .getConfig()
+                    .getBareConfig()
                     .getPluginConfig(e.getPluginName())
                     .getString(e.getExportName());
 
diff --git a/java/com/google/gerrit/server/group/db/RenameGroupOp.java b/java/com/google/gerrit/server/group/db/RenameGroupOp.java
index 4cc6138..b7564e2 100644
--- a/java/com/google/gerrit/server/group/db/RenameGroupOp.java
+++ b/java/com/google/gerrit/server/group/db/RenameGroupOp.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.server.git.DefaultQueueOp;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.project.CachedProjectConfig;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.inject.Inject;
@@ -30,6 +31,7 @@
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.Optional;
 import java.util.concurrent.Future;
 import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -87,10 +89,10 @@
   public void run() {
     Iterable<Project.NameKey> names = tryingAgain ? retryOn : projectCache.all();
     for (Project.NameKey projectName : names) {
-      ProjectConfig config =
+      CachedProjectConfig config =
           projectCache.get(projectName).orElseThrow(illegalState(projectName)).getConfig();
-      GroupReference ref = config.getGroup(uuid);
-      if (ref == null || newName.equals(ref.getName())) {
+      Optional<GroupReference> ref = config.getGroup(uuid);
+      if (!ref.isPresent() || newName.equals(ref.get().getName())) {
         continue;
       }
 
diff --git a/java/com/google/gerrit/server/mail/send/ProjectWatch.java b/java/com/google/gerrit/server/mail/send/ProjectWatch.java
index ef58744..e67ff01 100644
--- a/java/com/google/gerrit/server/mail/send/ProjectWatch.java
+++ b/java/com/google/gerrit/server/mail/send/ProjectWatch.java
@@ -92,7 +92,7 @@
     }
 
     for (ProjectState state : projectState.tree()) {
-      for (NotifyConfig nc : state.getConfig().getNotifyConfigs()) {
+      for (NotifyConfig nc : state.getConfig().getNotifySections().values()) {
         if (nc.isNotify(type)) {
           try {
             add(matching, state.getNameKey(), nc);
diff --git a/java/com/google/gerrit/server/project/AccountsSection.java b/java/com/google/gerrit/server/project/AccountsSection.java
index 30bd244..04f63f7 100644
--- a/java/com/google/gerrit/server/project/AccountsSection.java
+++ b/java/com/google/gerrit/server/project/AccountsSection.java
@@ -14,22 +14,16 @@
 
 package com.google.gerrit.server.project;
 
+import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.data.PermissionRule;
-import java.util.ArrayList;
 import java.util.List;
 
-public class AccountsSection {
-  protected List<PermissionRule> sameGroupVisibility;
+@AutoValue
+public abstract class AccountsSection {
+  public abstract ImmutableList<PermissionRule> getSameGroupVisibility();
 
-  public ImmutableList<PermissionRule> getSameGroupVisibility() {
-    if (sameGroupVisibility == null) {
-      sameGroupVisibility = ImmutableList.of();
-    }
-    return ImmutableList.copyOf(sameGroupVisibility);
-  }
-
-  public void setSameGroupVisibility(List<PermissionRule> sameGroupVisibility) {
-    this.sameGroupVisibility = new ArrayList<>(sameGroupVisibility);
+  public static AccountsSection create(List<PermissionRule> sameGroupVisibility) {
+    return new AutoValue_AccountsSection(ImmutableList.copyOf(sameGroupVisibility));
   }
 }
diff --git a/java/com/google/gerrit/server/project/CachedProjectConfig.java b/java/com/google/gerrit/server/project/CachedProjectConfig.java
new file mode 100644
index 0000000..241a146
--- /dev/null
+++ b/java/com/google/gerrit/server/project/CachedProjectConfig.java
@@ -0,0 +1,181 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.common.data.ContributorAgreement;
+import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.SubscribeSection;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.git.BranchOrderSection;
+import com.google.gerrit.server.git.NotifyConfig;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
+
+/**
+ * Cached representation of values parsed from {@link ProjectConfig}.
+ *
+ * <p>This class is immutable and thread-safe.
+ */
+@AutoValue
+public abstract class CachedProjectConfig {
+  public abstract Project getProject();
+
+  public abstract ImmutableMap<AccountGroup.UUID, GroupReference> getGroups();
+
+  /** Returns a set of all groups used by this configuration. */
+  public ImmutableSet<AccountGroup.UUID> getAllGroupUUIDs() {
+    return getGroups().keySet();
+  }
+
+  /**
+   * Returns the group reference for a {@link AccountGroup.UUID}, if the group is used by at least
+   * one rule.
+   */
+  public Optional<GroupReference> getGroup(AccountGroup.UUID uuid) {
+    return Optional.ofNullable(getGroups().get(uuid));
+  }
+
+  /** Returns the account section containing visibility information about accounts. */
+  public abstract AccountsSection getAccountsSection();
+
+  /** Returns a map of {@link AccessSection}s keyed by their name. */
+  public abstract ImmutableMap<String, AccessSection> getAccessSections();
+
+  /** Returns the {@link AccessSection} with to the given name. */
+  public Optional<AccessSection> getAccessSection(String refName) {
+    return Optional.ofNullable(getAccessSections().get(refName));
+  }
+
+  /** Returns all {@link AccessSection} names. */
+  public ImmutableSet<String> getAccessSectionNames() {
+    return ImmutableSet.copyOf(getAccessSections().keySet());
+  }
+
+  /**
+   * Returns the {@link BranchOrderSection} containing the order in which branches should be shown.
+   */
+  public abstract Optional<BranchOrderSection> getBranchOrderSection();
+
+  /** Returns the {@link ContributorAgreement}s keyed by their name. */
+  public abstract ImmutableMap<String, ContributorAgreement> getContributorAgreements();
+
+  /** Returns the {@link NotifyConfig}s keyed by their name. */
+  public abstract ImmutableMap<String, NotifyConfig> getNotifySections();
+
+  /** Returns the {@link LabelType}s keyed by their name. */
+  public abstract ImmutableMap<String, LabelType> getLabelSections();
+
+  /** Returns configured {@link ConfiguredMimeTypes}s. */
+  public abstract ConfiguredMimeTypes getMimeTypes();
+
+  /** Returns {@link SubscribeSection} keyed by the {@link Project.NameKey} they reference. */
+  public abstract ImmutableMap<Project.NameKey, SubscribeSection> getSubscribeSections();
+
+  /** Returns {@link StoredCommentLinkInfo} keyed by their name. */
+  public abstract ImmutableMap<String, StoredCommentLinkInfo> getCommentLinkSections();
+
+  /** Returns the blob ID of the {@code rules.pl} file, if present. */
+  public abstract Optional<ObjectId> getRulesId();
+
+  // TODO(hiesel): This should not have to be an Optional.
+  /** Returns the SHA1 of the {@code refs/meta/config} branch. */
+  public abstract Optional<ObjectId> getRevision();
+
+  /** Returns the maximum allowed object size. */
+  public abstract long getMaxObjectSizeLimit();
+
+  /** Returns {@code true} if received objects should be checked for validity. */
+  public abstract boolean getCheckReceivedObjects();
+
+  /** Returns a list of panel sections keyed by title. */
+  public abstract ImmutableMap<String, ImmutableList<String>> getExtensionPanelSections();
+
+  public ImmutableList<SubscribeSection> getSubscribeSections(BranchNameKey branch) {
+    return filterSubscribeSectionsByBranch(getSubscribeSections().values(), branch);
+  }
+
+  public static Builder builder() {
+    return new AutoValue_CachedProjectConfig.Builder();
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract Builder setProject(Project value);
+
+    public abstract Builder setGroups(ImmutableMap<AccountGroup.UUID, GroupReference> value);
+
+    public abstract Builder setAccountsSection(AccountsSection value);
+
+    public abstract Builder setAccessSections(ImmutableMap<String, AccessSection> value);
+
+    public abstract Builder setBranchOrderSection(Optional<BranchOrderSection> value);
+
+    public abstract Builder setContributorAgreements(
+        ImmutableMap<String, ContributorAgreement> value);
+
+    public abstract Builder setNotifySections(ImmutableMap<String, NotifyConfig> value);
+
+    public abstract Builder setLabelSections(ImmutableMap<String, LabelType> value);
+
+    public abstract Builder setMimeTypes(ConfiguredMimeTypes value);
+
+    public abstract Builder setSubscribeSections(
+        ImmutableMap<Project.NameKey, SubscribeSection> value);
+
+    public abstract Builder setCommentLinkSections(
+        ImmutableMap<String, StoredCommentLinkInfo> value);
+
+    public abstract Builder setRulesId(Optional<ObjectId> value);
+
+    public abstract Builder setRevision(Optional<ObjectId> value);
+
+    public abstract Builder setMaxObjectSizeLimit(long value);
+
+    public abstract Builder setCheckReceivedObjects(boolean value);
+
+    public abstract Builder setExtensionPanelSections(
+        ImmutableMap<String, ImmutableList<String>> value);
+
+    public Builder setExtensionPanelSections(Map<String, List<String>> value) {
+      ImmutableMap.Builder<String, ImmutableList<String>> b = ImmutableMap.builder();
+      value.entrySet().forEach(e -> b.put(e.getKey(), ImmutableList.copyOf(e.getValue())));
+      return setExtensionPanelSections(b.build());
+    }
+
+    public abstract CachedProjectConfig build();
+  }
+
+  private static ImmutableList<SubscribeSection> filterSubscribeSectionsByBranch(
+      Collection<SubscribeSection> allSubscribeSections, BranchNameKey branch) {
+    ImmutableList.Builder<SubscribeSection> ret = ImmutableList.builder();
+    for (SubscribeSection s : allSubscribeSections) {
+      if (s.appliesTo(branch)) {
+        ret.add(s);
+      }
+    }
+    return ret.build();
+  }
+}
diff --git a/java/com/google/gerrit/server/project/ContributorAgreementsChecker.java b/java/com/google/gerrit/server/project/ContributorAgreementsChecker.java
index c2eb79d..610ee64 100644
--- a/java/com/google/gerrit/server/project/ContributorAgreementsChecker.java
+++ b/java/com/google/gerrit/server/project/ContributorAgreementsChecker.java
@@ -90,7 +90,7 @@
 
     IdentifiedUser iUser = user.asIdentifiedUser();
     Collection<ContributorAgreement> contributorAgreements =
-        projectCache.getAllProjects().getConfig().getContributorAgreements();
+        projectCache.getAllProjects().getConfig().getContributorAgreements().values();
     List<UUID> okGroupIds = new ArrayList<>();
     for (ContributorAgreement ca : contributorAgreements) {
       List<AccountGroup.UUID> groupIds;
diff --git a/java/com/google/gerrit/server/project/GroupList.java b/java/com/google/gerrit/server/project/GroupList.java
index 9b65413..33c73e8 100644
--- a/java/com/google/gerrit/server/project/GroupList.java
+++ b/java/com/google/gerrit/server/project/GroupList.java
@@ -70,6 +70,10 @@
     return byUUID.get(uuid);
   }
 
+  public Map<AccountGroup.UUID, GroupReference> byUUID() {
+    return byUUID;
+  }
+
   @Nullable
   public GroupReference byName(String name) {
     return byUUID.entrySet().stream()
diff --git a/java/com/google/gerrit/server/project/ProjectCacheImpl.java b/java/com/google/gerrit/server/project/ProjectCacheImpl.java
index a5c07e5..5f3fbeb 100644
--- a/java/com/google/gerrit/server/project/ProjectCacheImpl.java
+++ b/java/com/google/gerrit/server/project/ProjectCacheImpl.java
@@ -297,7 +297,7 @@
         try (Repository git = mgr.openRepository(key)) {
           Ref configRef = git.exactRef(RefNames.REFS_CONFIG);
           if (configRef != null
-              && configRef.getObjectId().equals(oldState.getConfig().getRevision())) {
+              && configRef.getObjectId().equals(oldState.getBareConfig().getRevision())) {
             refreshCounter.increment(CACHE_NAME, false);
             return Futures.immediateFuture(oldState);
           }
diff --git a/java/com/google/gerrit/server/project/ProjectConfig.java b/java/com/google/gerrit/server/project/ProjectConfig.java
index cc13606..727bf44 100644
--- a/java/com/google/gerrit/server/project/ProjectConfig.java
+++ b/java/com/google/gerrit/server/project/ProjectConfig.java
@@ -28,7 +28,7 @@
 import com.google.common.base.Splitter;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Maps;
 import com.google.common.primitives.Shorts;
 import com.google.gerrit.common.Nullable;
@@ -47,7 +47,6 @@
 import com.google.gerrit.common.data.SubscribeSection;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.BooleanProjectConfig;
-import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.InvalidNameException;
@@ -252,6 +251,28 @@
   private boolean hasLegacyPermissions;
   private Map<String, List<String>> extensionPanelSections;
 
+  /** Returns an immutable, thread-safe representation of this object that can be cached. */
+  public CachedProjectConfig getCacheable() {
+    return CachedProjectConfig.builder()
+        .setProject(project)
+        .setAccountsSection(accountsSection)
+        .setGroups(ImmutableMap.copyOf(groupList.byUUID()))
+        .setAccessSections(ImmutableMap.copyOf(accessSections))
+        .setBranchOrderSection(Optional.ofNullable(branchOrderSection))
+        .setContributorAgreements(ImmutableMap.copyOf(contributorAgreements))
+        .setNotifySections(ImmutableMap.copyOf(notifySections))
+        .setLabelSections(ImmutableMap.copyOf(labelSections))
+        .setMimeTypes(mimeTypes)
+        .setSubscribeSections(ImmutableMap.copyOf(subscribeSections))
+        .setCommentLinkSections(ImmutableMap.copyOf(commentLinkSections))
+        .setRulesId(Optional.ofNullable(rulesId))
+        .setRevision(Optional.ofNullable(getRevision()))
+        .setMaxObjectSizeLimit(maxObjectSizeLimit)
+        .setCheckReceivedObjects(checkReceivedObjects)
+        .setExtensionPanelSections(extensionPanelSections)
+        .build();
+  }
+
   public static StoredCommentLinkInfo buildCommentLink(Config cfg, String name, boolean allowRaw)
       throws IllegalArgumentException {
     String match = cfg.getString(COMMENTLINK, name, KEY_MATCH);
@@ -345,25 +366,21 @@
     return accountsSection;
   }
 
-  public Map<String, List<String>> getExtensionPanelSections() {
-    return extensionPanelSections;
+  public void setAccountsSection(AccountsSection accountsSection) {
+    this.accountsSection = accountsSection;
   }
 
   public AccessSection getAccessSection(String name) {
-    return getAccessSection(name, false);
+    return accessSections.get(name);
   }
 
-  public AccessSection getAccessSection(String name, boolean create) {
-    AccessSection as = accessSections.get(name);
-    if (as == null && create) {
-      as = new AccessSection(name);
-      accessSections.put(name, as);
-    }
-    return as;
-  }
-
-  public ImmutableSet<String> getAccessSectionNames() {
-    return ImmutableSet.copyOf(accessSections.keySet());
+  public void upsertAccessSection(String name, Consumer<AccessSection.Builder> update) {
+    AccessSection.Builder accessSectionBuilder =
+        accessSections.containsKey(name)
+            ? accessSections.get(name).toBuilder()
+            : AccessSection.builder(name);
+    update.accept(accessSectionBuilder);
+    accessSections.put(name, accessSectionBuilder.build());
   }
 
   public Collection<AccessSection> getAccessSections() {
@@ -382,16 +399,6 @@
     return subscribeSections;
   }
 
-  public Collection<SubscribeSection> getSubscribeSections(BranchNameKey branch) {
-    Collection<SubscribeSection> ret = new ArrayList<>();
-    for (SubscribeSection s : subscribeSections.values()) {
-      if (s.appliesTo(branch)) {
-        ret.add(s);
-      }
-    }
-    return ret;
-  }
-
   public void addSubscribeSection(SubscribeSection s) {
     subscribeSections.put(s.project(), s);
   }
@@ -400,8 +407,9 @@
     if (section != null) {
       String name = section.getName();
       if (sectionsWithUnknownPermissions.contains(name)) {
-        AccessSection a = accessSections.get(name);
-        a.setPermissions(new ArrayList<>());
+        AccessSection.Builder a = accessSections.get(name).toBuilder();
+        a.modifyPermissions(p -> p.clear());
+        accessSections.put(name, a.build());
       } else {
         accessSections.remove(name);
       }
@@ -412,8 +420,9 @@
     if (permission == null) {
       remove(section);
     } else if (section != null) {
-      AccessSection a = accessSections.get(section.getName());
-      a.remove(permission);
+      AccessSection a =
+          accessSections.get(section.getName()).toBuilder().remove(permission.toBuilder()).build();
+      accessSections.put(section.getName(), a);
       if (a.getPermissions().isEmpty()) {
         remove(a);
       }
@@ -432,28 +441,21 @@
       if (p == null) {
         return;
       }
-      p.remove(rule);
-      if (p.getRules().isEmpty()) {
-        a.remove(permission);
+      AccessSection.Builder accessSectionBuilder = a.toBuilder();
+      Permission.Builder permissionBuilder =
+          accessSectionBuilder.upsertPermission(permission.getName());
+      permissionBuilder.remove(rule);
+      if (permissionBuilder.build().getRules().isEmpty()) {
+        accessSectionBuilder.remove(permissionBuilder);
       }
+      a = accessSectionBuilder.build();
+      accessSections.put(section.getName(), a);
       if (a.getPermissions().isEmpty()) {
         remove(a);
       }
     }
   }
 
-  public void replace(AccessSection section) {
-    for (Permission permission : section.getPermissions()) {
-      ImmutableList.Builder<PermissionRule> newRules = ImmutableList.builder();
-      for (PermissionRule rule : permission.getRules()) {
-        newRules.add(rule.toBuilder().setGroup(resolve(rule.getGroup())).build());
-      }
-      permission.setRules(newRules.build());
-    }
-
-    accessSections.put(section.getName(), section);
-  }
-
   public ContributorAgreement getContributorAgreement(String name) {
     return contributorAgreements.get(name);
   }
@@ -541,11 +543,6 @@
     return groupList.byName(groupName);
   }
 
-  /** @return set of all groups used by this configuration. */
-  public Set<AccountGroup.UUID> getAllGroupUUIDs() {
-    return groupList.uuids();
-  }
-
   /**
    * @return the project's rules.pl ObjectId, if present in the branch. Null if it doesn't exist.
    */
@@ -655,9 +652,9 @@
   }
 
   private void loadAccountsSection(Config rc) {
-    accountsSection = new AccountsSection();
-    accountsSection.setSameGroupVisibility(
-        loadPermissionRules(rc, ACCOUNTS, null, KEY_SAME_GROUP_VISIBILITY, false));
+    accountsSection =
+        AccountsSection.create(
+            loadPermissionRules(rc, ACCOUNTS, null, KEY_SAME_GROUP_VISIBILITY, false));
   }
 
   private void loadExtensionPanelSections(Config rc) {
@@ -790,38 +787,41 @@
     sectionsWithUnknownPermissions = new HashSet<>();
     for (String refName : rc.getSubsections(ACCESS)) {
       if (AccessSection.isValidRefSectionName(refName) && isValidRegex(refName)) {
-        AccessSection as = getAccessSection(refName, true);
+        upsertAccessSection(
+            refName,
+            as -> {
+              for (String varName : rc.getStringList(ACCESS, refName, KEY_GROUP_PERMISSIONS)) {
+                for (String n : Splitter.on(EXCLUSIVE_PERMISSIONS_SPLIT_PATTERN).split(varName)) {
+                  n = convertLegacyPermission(n);
+                  if (isCoreOrPluginPermission(n)) {
+                    as.upsertPermission(n).setExclusiveGroup(true);
+                  }
+                }
+              }
 
-        for (String varName : rc.getStringList(ACCESS, refName, KEY_GROUP_PERMISSIONS)) {
-          for (String n : Splitter.on(EXCLUSIVE_PERMISSIONS_SPLIT_PATTERN).split(varName)) {
-            n = convertLegacyPermission(n);
-            if (isCoreOrPluginPermission(n)) {
-              as.getPermission(n, true).setExclusiveGroup(true);
-            }
-          }
-        }
-
-        for (String varName : rc.getNames(ACCESS, refName)) {
-          String convertedName = convertLegacyPermission(varName);
-          if (isCoreOrPluginPermission(convertedName)) {
-            Permission perm = as.getPermission(convertedName, true);
-            loadPermissionRules(
-                rc, ACCESS, refName, varName, perm, Permission.hasRange(convertedName));
-          } else {
-            sectionsWithUnknownPermissions.add(as.getName());
-          }
-        }
+              for (String varName : rc.getNames(ACCESS, refName)) {
+                String convertedName = convertLegacyPermission(varName);
+                if (isCoreOrPluginPermission(convertedName)) {
+                  Permission.Builder perm = as.upsertPermission(convertedName);
+                  loadPermissionRules(
+                      rc, ACCESS, refName, varName, perm, Permission.hasRange(convertedName));
+                } else {
+                  sectionsWithUnknownPermissions.add(as.getName());
+                }
+              }
+            });
       }
     }
 
-    AccessSection capability = null;
+    AccessSection.Builder capability = null;
     for (String varName : rc.getNames(CAPABILITY)) {
       if (capability == null) {
-        capability = new AccessSection(AccessSection.GLOBAL_CAPABILITIES);
-        accessSections.put(AccessSection.GLOBAL_CAPABILITIES, capability);
+        capability = AccessSection.builder(AccessSection.GLOBAL_CAPABILITIES);
+        accessSections.put(AccessSection.GLOBAL_CAPABILITIES, capability.build());
       }
-      Permission perm = capability.getPermission(varName, true);
+      Permission.Builder perm = capability.upsertPermission(varName);
       loadPermissionRules(rc, CAPABILITY, null, varName, perm, GlobalCapability.hasRange(varName));
+      accessSections.put(AccessSection.GLOBAL_CAPABILITIES, capability.build());
     }
   }
 
@@ -874,9 +874,9 @@
 
   private ImmutableList<PermissionRule> loadPermissionRules(
       Config rc, String section, String subsection, String varName, boolean useRange) {
-    Permission perm = new Permission(varName);
+    Permission.Builder perm = Permission.builder(varName);
     loadPermissionRules(rc, section, subsection, varName, perm, useRange);
-    return ImmutableList.copyOf(perm.getRules());
+    return ImmutableList.copyOf(perm.build().getRules());
   }
 
   private void loadPermissionRules(
@@ -884,7 +884,7 @@
       String section,
       String subsection,
       String varName,
-      Permission perm,
+      Permission.Builder perm,
       boolean useRange) {
     for (String ruleString : rc.getStringList(section, subsection, varName)) {
       PermissionRule rule;
@@ -916,7 +916,7 @@
                 PROJECT_CONFIG, "group \"" + ref.getName() + "\" not in " + GroupList.FILE_NAME));
       }
 
-      perm.add(rule.toBuilder().setGroup(ref).build());
+      perm.add(rule.toBuilder().setGroup(ref));
     }
   }
 
diff --git a/java/com/google/gerrit/server/project/ProjectCreator.java b/java/com/google/gerrit/server/project/ProjectCreator.java
index 5ec4a58..fa0a262 100644
--- a/java/com/google/gerrit/server/project/ProjectCreator.java
+++ b/java/com/google/gerrit/server/project/ProjectCreator.java
@@ -179,14 +179,17 @@
           });
 
       if (!args.ownerIds.isEmpty()) {
-        AccessSection all = config.getAccessSection(AccessSection.ALL, true);
-        for (AccountGroup.UUID ownerId : args.ownerIds) {
-          GroupDescription.Basic g = groupBackend.get(ownerId);
-          if (g != null) {
-            GroupReference group = config.resolve(GroupReference.forGroup(g));
-            all.getPermission(Permission.OWNER, true).add(PermissionRule.create(group));
-          }
-        }
+        config.upsertAccessSection(
+            AccessSection.ALL,
+            all -> {
+              for (AccountGroup.UUID ownerId : args.ownerIds) {
+                GroupDescription.Basic g = groupBackend.get(ownerId);
+                if (g != null) {
+                  GroupReference group = config.resolve(GroupReference.forGroup(g));
+                  all.upsertPermission(Permission.OWNER).add(PermissionRule.builder(group));
+                }
+              }
+            });
       }
 
       md.setMessage("Created project\n");
diff --git a/java/com/google/gerrit/server/project/ProjectState.java b/java/com/google/gerrit/server/project/ProjectState.java
index 6353103..616809b 100644
--- a/java/com/google/gerrit/server/project/ProjectState.java
+++ b/java/com/google/gerrit/server/project/ProjectState.java
@@ -54,7 +54,6 @@
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
-import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -79,6 +78,7 @@
   private final List<CommentLinkInfo> commentLinks;
 
   private final ProjectConfig config;
+  private final CachedProjectConfig cachedConfig;
   private final Map<String, ProjectLevelConfig> configs;
   private final Set<AccountGroup.UUID> localOwners;
   private final long globalMaxObjectSizeLimit;
@@ -107,6 +107,7 @@
     this.gitMgr = gitMgr;
     this.commentLinks = commentLinks;
     this.config = config;
+    this.cachedConfig = config.getCacheable();
     this.configs = new HashMap<>();
     this.capabilities =
         isAllProjects
@@ -149,15 +150,15 @@
    */
   public boolean hasPrologRules() {
     // We check if this project has a rules.pl file
-    if (getConfig().getRulesId() != null) {
+    if (getConfig().getRulesId().isPresent()) {
       return true;
     }
 
     // If not, we check the parents.
     return parents().stream()
         .map(ProjectState::getConfig)
-        .map(ProjectConfig::getRulesId)
-        .anyMatch(Objects::nonNull);
+        .map(CachedProjectConfig::getRulesId)
+        .anyMatch(Optional::isPresent);
   }
 
   public Project getProject() {
@@ -172,7 +173,12 @@
     return getNameKey().get();
   }
 
-  public ProjectConfig getConfig() {
+  public CachedProjectConfig getConfig() {
+    return cachedConfig;
+  }
+
+  // TODO(hiesel): Remove this method.
+  public ProjectConfig getBareConfig() {
     return config;
   }
 
@@ -280,14 +286,16 @@
       sm = new ArrayList<>(fromConfig.size());
       for (AccessSection section : fromConfig) {
         if (isAllProjects) {
-          List<Permission> copy = Lists.newArrayListWithCapacity(section.getPermissions().size());
+          List<Permission.Builder> copy = new ArrayList<>();
           for (Permission p : section.getPermissions()) {
             if (Permission.canBeOnAllProjects(section.getName(), p.getName())) {
-              copy.add(p);
+              copy.add(p.toBuilder());
             }
           }
-          section = new AccessSection(section.getName());
-          section.setPermissions(copy);
+          section =
+              AccessSection.builder(section.getName())
+                  .modifyPermissions(permissions -> permissions.addAll(copy))
+                  .build();
         }
 
         SectionMatcher matcher = SectionMatcher.wrap(getNameKey(), section);
@@ -457,7 +465,7 @@
       cls.put(cl.name.toLowerCase(), cl);
     }
     for (ProjectState s : treeInOrder()) {
-      for (StoredCommentLinkInfo cl : s.getConfig().getCommentLinkSections()) {
+      for (StoredCommentLinkInfo cl : s.getConfig().getCommentLinkSections().values()) {
         String name = cl.getName().toLowerCase();
         if (cl.getOverrideOnly()) {
           CommentLinkInfo parent = cls.get(name);
@@ -473,14 +481,14 @@
     return ImmutableList.copyOf(cls.values());
   }
 
-  public BranchOrderSection getBranchOrderSection() {
+  public Optional<BranchOrderSection> getBranchOrderSection() {
     for (ProjectState s : tree()) {
-      BranchOrderSection section = s.getConfig().getBranchOrderSection();
-      if (section != null) {
+      Optional<BranchOrderSection> section = s.getConfig().getBranchOrderSection();
+      if (section.isPresent()) {
         return section;
       }
     }
-    return null;
+    return Optional.empty();
   }
 
   public Collection<SubscribeSection> getSubscribeSections(BranchNameKey branch) {
diff --git a/java/com/google/gerrit/server/restapi/account/GetAgreements.java b/java/com/google/gerrit/server/restapi/account/GetAgreements.java
index aeaeb1c..c11fc7e 100644
--- a/java/com/google/gerrit/server/restapi/account/GetAgreements.java
+++ b/java/com/google/gerrit/server/restapi/account/GetAgreements.java
@@ -96,7 +96,7 @@
 
     List<AgreementInfo> results = new ArrayList<>();
     Collection<ContributorAgreement> cas =
-        projectCache.getAllProjects().getConfig().getContributorAgreements();
+        projectCache.getAllProjects().getConfig().getContributorAgreements().values();
     for (ContributorAgreement ca : cas) {
       List<AccountGroup.UUID> groupIds = new ArrayList<>();
       for (PermissionRule rule : ca.getAccepted()) {
diff --git a/java/com/google/gerrit/server/restapi/account/PutAgreement.java b/java/com/google/gerrit/server/restapi/account/PutAgreement.java
index 42504a0..ce6f287 100644
--- a/java/com/google/gerrit/server/restapi/account/PutAgreement.java
+++ b/java/com/google/gerrit/server/restapi/account/PutAgreement.java
@@ -82,7 +82,7 @@
 
     String agreementName = Strings.nullToEmpty(input.name);
     ContributorAgreement ca =
-        projectCache.getAllProjects().getConfig().getContributorAgreement(agreementName);
+        projectCache.getAllProjects().getConfig().getContributorAgreements().get(agreementName);
     if (ca == null) {
       throw new UnprocessableEntityException("contributor agreement not found");
     }
diff --git a/java/com/google/gerrit/server/restapi/change/Mergeable.java b/java/com/google/gerrit/server/restapi/change/Mergeable.java
index 6fccdd1..3338543 100644
--- a/java/com/google/gerrit/server/restapi/change/Mergeable.java
+++ b/java/com/google/gerrit/server/restapi/change/Mergeable.java
@@ -44,6 +44,7 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
+import java.util.Optional;
 import java.util.concurrent.Future;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
@@ -116,10 +117,10 @@
 
       if (otherBranches) {
         result.mergeableInto = new ArrayList<>();
-        BranchOrderSection branchOrder = projectState.getBranchOrderSection();
-        if (branchOrder != null) {
+        Optional<BranchOrderSection> branchOrder = projectState.getBranchOrderSection();
+        if (branchOrder.isPresent()) {
           int prefixLen = Constants.R_HEADS.length();
-          List<String> names = branchOrder.getMoreStable(ref.getName());
+          List<String> names = branchOrder.get().getMoreStable(ref.getName());
           Map<String, Ref> refs =
               git.getRefDatabase().exactRef(names.toArray(new String[names.size()]));
           for (String n : names) {
diff --git a/java/com/google/gerrit/server/restapi/config/GetServerInfo.java b/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
index 0198c36..1f4b468 100644
--- a/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
+++ b/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
@@ -174,7 +174,7 @@
 
     if (info.useContributorAgreements != null) {
       Collection<ContributorAgreement> agreements =
-          projectCache.getAllProjects().getConfig().getContributorAgreements();
+          projectCache.getAllProjects().getConfig().getContributorAgreements().values();
       if (!agreements.isEmpty()) {
         info.contributorAgreements = Lists.newArrayListWithCapacity(agreements.size());
         for (ContributorAgreement agreement : agreements) {
diff --git a/java/com/google/gerrit/server/restapi/project/GetAccess.java b/java/com/google/gerrit/server/restapi/project/GetAccess.java
index 5a3dbcd..2d1191f 100644
--- a/java/com/google/gerrit/server/restapi/project/GetAccess.java
+++ b/java/com/google/gerrit/server/restapi/project/GetAccess.java
@@ -157,7 +157,7 @@
         projectState = projectCache.get(projectName).orElseThrow(illegalState(projectName));
         perm = permissionBackend.currentUser().project(projectName);
       } else if (config.getRevision() != null
-          && !config.getRevision().equals(projectState.getConfig().getRevision())) {
+          && !config.getRevision().equals(projectState.getConfig().getRevision().orElse(null))) {
         projectCache.evict(config.getProject());
         projectState = projectCache.get(projectName).orElseThrow(illegalState(projectName));
         perm = permissionBackend.currentUser().project(projectName);
@@ -208,9 +208,9 @@
           // user is a member of, as well as groups they own or that
           // are visible to all users.
 
-          AccessSection dst = null;
+          AccessSection.Builder dst = null;
           for (Permission srcPerm : section.getPermissions()) {
-            Permission dstPerm = null;
+            Permission.Builder dstPerm = null;
 
             for (PermissionRule srcRule : srcPerm.getRules()) {
               AccountGroup.UUID groupId = srcRule.getGroup().getUUID();
@@ -221,12 +221,12 @@
               loadGroup(groups, groupId);
               if (dstPerm == null) {
                 if (dst == null) {
-                  dst = new AccessSection(name);
-                  info.local.put(name, createAccessSection(groups, dst));
+                  dst = AccessSection.builder(name);
+                  info.local.put(name, createAccessSection(groups, dst.build()));
                 }
-                dstPerm = dst.getPermission(srcPerm.getName(), true);
+                dstPerm = dst.upsertPermission(srcPerm.getName());
               }
-              dstPerm.add(srcRule);
+              dstPerm.add(srcRule.toBuilder());
             }
           }
         }
diff --git a/java/com/google/gerrit/server/restapi/project/SetAccessUtil.java b/java/com/google/gerrit/server/restapi/project/SetAccessUtil.java
index 0f7bc3d..d1511c1 100644
--- a/java/com/google/gerrit/server/restapi/project/SetAccessUtil.java
+++ b/java/com/google/gerrit/server/restapi/project/SetAccessUtil.java
@@ -78,14 +78,14 @@
         continue;
       }
 
-      AccessSection accessSection = new AccessSection(entry.getKey());
+      AccessSection.Builder accessSection = AccessSection.builder(entry.getKey());
       for (Map.Entry<String, PermissionInfo> permissionEntry :
           entry.getValue().permissions.entrySet()) {
         if (permissionEntry.getValue().rules == null) {
           continue;
         }
 
-        Permission p = new Permission(permissionEntry.getKey());
+        Permission.Builder p = Permission.builder(permissionEntry.getKey());
         if (permissionEntry.getValue().exclusive != null) {
           p.setExclusiveGroup(permissionEntry.getValue().exclusive);
         }
@@ -114,11 +114,11 @@
               r.setForce(pri.force);
             }
           }
-          p.add(r.build());
+          p.add(r);
         }
         accessSection.addPermission(p);
       }
-      sections.add(accessSection);
+      sections.add(accessSection.build());
     }
     return sections;
   }
@@ -193,25 +193,23 @@
 
     // Apply additions
     for (AccessSection section : additions) {
-      AccessSection currentAccessSection = config.getAccessSection(section.getName());
-
-      if (currentAccessSection == null) {
-        // Add AccessSection
-        config.replace(section);
-      } else {
-        for (Permission p : section.getPermissions()) {
-          Permission currentPermission = currentAccessSection.getPermission(p.getName());
-          if (currentPermission == null) {
-            // Add Permission
-            currentAccessSection.addPermission(p);
-          } else {
-            for (PermissionRule r : p.getRules()) {
-              // AddPermissionRule
-              currentPermission.add(r);
+      config.upsertAccessSection(
+          section.getName(),
+          existingAccessSection -> {
+            for (Permission p : section.getPermissions()) {
+              Permission currentPermission =
+                  existingAccessSection.build().getPermission(p.getName());
+              if (currentPermission == null) {
+                // Add Permission
+                existingAccessSection.addPermission(p.toBuilder());
+              } else {
+                for (PermissionRule r : p.getRules()) {
+                  // AddPermissionRule
+                  existingAccessSection.upsertPermission(p.getName()).add(r.toBuilder());
+                }
+              }
             }
-          }
-        }
-      }
+          });
     }
   }
 
diff --git a/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java b/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java
index 5f1268b..87f5758 100644
--- a/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java
+++ b/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java
@@ -453,7 +453,7 @@
       } else {
         pmc =
             rulesCache.loadMachine(
-                projectState.getNameKey(), projectState.getConfig().getRulesId());
+                projectState.getNameKey(), projectState.getConfig().getRulesId().orElse(null));
       }
       env = envFactory.create(pmc);
     } catch (CompileException err) {
@@ -490,7 +490,7 @@
         parentEnv =
             envFactory.create(
                 rulesCache.loadMachine(
-                    parentState.getNameKey(), parentState.getConfig().getRulesId()));
+                    parentState.getNameKey(), parentState.getConfig().getRulesId().orElse(null)));
       } catch (CompileException err) {
         throw new RuleEvalException("Cannot consult rules.pl for " + parentState.getName(), err);
       }
diff --git a/java/com/google/gerrit/server/rules/RulesCache.java b/java/com/google/gerrit/server/rules/RulesCache.java
index ed67e68..8b72714 100644
--- a/java/com/google/gerrit/server/rules/RulesCache.java
+++ b/java/com/google/gerrit/server/rules/RulesCache.java
@@ -55,6 +55,7 @@
 import java.util.EnumSet;
 import java.util.List;
 import java.util.concurrent.ExecutionException;
+import org.eclipse.jgit.annotations.Nullable;
 import org.eclipse.jgit.errors.LargeObjectException;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Constants;
@@ -128,8 +129,8 @@
    * @return a Prolog machine, after loading the specified rules.
    * @throws CompileException the machine cannot be created.
    */
-  public synchronized PrologMachineCopy loadMachine(Project.NameKey project, ObjectId rulesId)
-      throws CompileException {
+  public synchronized PrologMachineCopy loadMachine(
+      @Nullable Project.NameKey project, @Nullable ObjectId rulesId) throws CompileException {
     if (!enableProjectRules || project == null || rulesId == null) {
       return defaultMachine;
     }
diff --git a/java/com/google/gerrit/server/schema/AclUtil.java b/java/com/google/gerrit/server/schema/AclUtil.java
index ed4a6ec..e65568f 100644
--- a/java/com/google/gerrit/server/schema/AclUtil.java
+++ b/java/com/google/gerrit/server/schema/AclUtil.java
@@ -27,13 +27,16 @@
  */
 public class AclUtil {
   public static void grant(
-      ProjectConfig config, AccessSection section, String permission, GroupReference... groupList) {
+      ProjectConfig config,
+      AccessSection.Builder section,
+      String permission,
+      GroupReference... groupList) {
     grant(config, section, permission, false, groupList);
   }
 
   public static void grant(
       ProjectConfig config,
-      AccessSection section,
+      AccessSection.Builder section,
       String permission,
       boolean force,
       GroupReference... groupList) {
@@ -42,35 +45,38 @@
 
   public static void grant(
       ProjectConfig config,
-      AccessSection section,
+      AccessSection.Builder section,
       String permission,
       boolean force,
       Boolean exclusive,
       GroupReference... groupList) {
-    Permission p = section.getPermission(permission, true);
+    Permission.Builder p = section.upsertPermission(permission);
     if (exclusive != null) {
       p.setExclusiveGroup(exclusive);
     }
     for (GroupReference group : groupList) {
       if (group != null) {
-        p.add(rule(config, group).setForce(force).build());
+        p.add(rule(config, group).setForce(force));
       }
     }
   }
 
   public static void block(
-      ProjectConfig config, AccessSection section, String permission, GroupReference... groupList) {
-    Permission p = section.getPermission(permission, true);
+      ProjectConfig config,
+      AccessSection.Builder section,
+      String permission,
+      GroupReference... groupList) {
+    Permission.Builder p = section.upsertPermission(permission);
     for (GroupReference group : groupList) {
       if (group != null) {
-        p.add(rule(config, group).setBlock().build());
+        p.add(rule(config, group).setBlock());
       }
     }
   }
 
   public static void grant(
       ProjectConfig config,
-      AccessSection section,
+      AccessSection.Builder section,
       LabelType type,
       int min,
       int max,
@@ -80,18 +86,18 @@
 
   public static void grant(
       ProjectConfig config,
-      AccessSection section,
+      AccessSection.Builder section,
       LabelType type,
       int min,
       int max,
       boolean exclusive,
       GroupReference... groupList) {
     String name = Permission.LABEL + type.getName();
-    Permission p = section.getPermission(name, true);
+    Permission.Builder p = section.upsertPermission(name);
     p.setExclusiveGroup(exclusive);
     for (GroupReference group : groupList) {
       if (group != null) {
-        p.add(rule(config, group).setRange(min, max).build());
+        p.add(rule(config, group).setRange(min, max));
       }
     }
   }
@@ -101,8 +107,11 @@
   }
 
   public static void remove(
-      ProjectConfig config, AccessSection section, String permission, GroupReference... groupList) {
-    Permission p = section.getPermission(permission, true);
+      ProjectConfig config,
+      AccessSection.Builder section,
+      String permission,
+      GroupReference... groupList) {
+    Permission.Builder p = section.upsertPermission(permission);
     for (GroupReference group : groupList) {
       if (group != null) {
         p.remove(rule(config, group).build());
diff --git a/java/com/google/gerrit/server/schema/AllProjectsCreator.java b/java/com/google/gerrit/server/schema/AllProjectsCreator.java
index 908ce0a..0fb282e 100644
--- a/java/com/google/gerrit/server/schema/AllProjectsCreator.java
+++ b/java/com/google/gerrit/server/schema/AllProjectsCreator.java
@@ -144,79 +144,107 @@
   }
 
   private void initDefaultAcls(ProjectConfig config, AllProjectsInput input) {
-    AccessSection capabilities = config.getAccessSection(AccessSection.GLOBAL_CAPABILITIES, true);
-    AccessSection heads = config.getAccessSection(AccessSection.HEADS, true);
-
     checkArgument(input.codeReviewLabel().isPresent());
     LabelType codeReviewLabel = input.codeReviewLabel().get();
 
-    initDefaultAclsForRegisteredUsers(heads, codeReviewLabel, config);
+    config.upsertAccessSection(
+        AccessSection.HEADS,
+        heads -> {
+          initDefaultAclsForRegisteredUsers(heads, codeReviewLabel, config);
+        });
 
-    input
-        .batchUsersGroup()
-        .ifPresent(
-            batchUsersGroup -> initDefaultAclsForBatchUsers(capabilities, config, batchUsersGroup));
+    config.upsertAccessSection(
+        AccessSection.GLOBAL_CAPABILITIES,
+        capabilities -> {
+          input
+              .batchUsersGroup()
+              .ifPresent(
+                  batchUsersGroup ->
+                      initDefaultAclsForBatchUsers(capabilities, config, batchUsersGroup));
+        });
 
     input
         .administratorsGroup()
-        .ifPresent(
-            adminsGroup ->
-                initDefaultAclsForAdmins(
-                    capabilities, config, heads, codeReviewLabel, adminsGroup));
+        .ifPresent(adminsGroup -> initDefaultAclsForAdmins(config, codeReviewLabel, adminsGroup));
   }
 
   private void initDefaultAclsForRegisteredUsers(
-      AccessSection heads, LabelType codeReviewLabel, ProjectConfig config) {
-    AccessSection refsFor = config.getAccessSection("refs/for/*", true);
-    AccessSection magic = config.getAccessSection("refs/for/" + AccessSection.ALL, true);
-    AccessSection all = config.getAccessSection("refs/*", true);
+      AccessSection.Builder heads, LabelType codeReviewLabel, ProjectConfig config) {
+    config.upsertAccessSection(
+        "refs/for/*",
+        refsFor -> {
+          grant(config, refsFor, Permission.ADD_PATCH_SET, registered);
+        });
 
-    grant(config, refsFor, Permission.ADD_PATCH_SET, registered);
     grant(config, heads, codeReviewLabel, -1, 1, registered);
     grant(config, heads, Permission.FORGE_AUTHOR, registered);
-    grant(config, all, Permission.REVERT, registered);
-    grant(config, magic, Permission.PUSH, registered);
-    grant(config, magic, Permission.PUSH_MERGE, registered);
+
+    config.upsertAccessSection(
+        "refs/*",
+        all -> {
+          grant(config, all, Permission.REVERT, registered);
+        });
+
+    config.upsertAccessSection(
+        "refs/for/" + AccessSection.ALL,
+        magic -> {
+          grant(config, magic, Permission.PUSH, registered);
+          grant(config, magic, Permission.PUSH_MERGE, registered);
+        });
   }
 
   private void initDefaultAclsForBatchUsers(
-      AccessSection capabilities, ProjectConfig config, GroupReference batchUsersGroup) {
-    Permission priority = capabilities.getPermission(GlobalCapability.PRIORITY, true);
-    priority.add(rule(config, batchUsersGroup).setAction(Action.BATCH).build());
+      AccessSection.Builder capabilities, ProjectConfig config, GroupReference batchUsersGroup) {
+    Permission.Builder priority = capabilities.upsertPermission(GlobalCapability.PRIORITY);
+    priority.add(rule(config, batchUsersGroup).setAction(Action.BATCH));
 
-    Permission stream = capabilities.getPermission(GlobalCapability.STREAM_EVENTS, true);
-    stream.add(rule(config, batchUsersGroup).build());
+    Permission.Builder stream = capabilities.upsertPermission(GlobalCapability.STREAM_EVENTS);
+    stream.add(rule(config, batchUsersGroup));
   }
 
   private void initDefaultAclsForAdmins(
-      AccessSection capabilities,
-      ProjectConfig config,
-      AccessSection heads,
-      LabelType codeReviewLabel,
-      GroupReference adminsGroup) {
-    AccessSection all = config.getAccessSection(AccessSection.ALL, true);
-    AccessSection tags = config.getAccessSection("refs/tags/*", true);
-    AccessSection meta = config.getAccessSection(RefNames.REFS_CONFIG, true);
+      ProjectConfig config, LabelType codeReviewLabel, GroupReference adminsGroup) {
+    config.upsertAccessSection(
+        AccessSection.GLOBAL_CAPABILITIES,
+        capabilities -> {
+          grant(config, capabilities, GlobalCapability.ADMINISTRATE_SERVER, adminsGroup);
+        });
 
-    grant(config, capabilities, GlobalCapability.ADMINISTRATE_SERVER, adminsGroup);
-    grant(config, all, Permission.READ, adminsGroup, anonymous);
-    grant(config, heads, codeReviewLabel, -2, 2, adminsGroup, owners);
-    grant(config, heads, Permission.CREATE, adminsGroup, owners);
-    grant(config, heads, Permission.PUSH, adminsGroup, owners);
-    grant(config, heads, Permission.SUBMIT, adminsGroup, owners);
-    grant(config, heads, Permission.FORGE_COMMITTER, adminsGroup, owners);
-    grant(config, heads, Permission.EDIT_TOPIC_NAME, true, adminsGroup, owners);
+    config.upsertAccessSection(
+        AccessSection.ALL,
+        all -> {
+          grant(config, all, Permission.READ, adminsGroup, anonymous);
+        });
 
-    grant(config, tags, Permission.CREATE, adminsGroup, owners);
-    grant(config, tags, Permission.CREATE_TAG, adminsGroup, owners);
-    grant(config, tags, Permission.CREATE_SIGNED_TAG, adminsGroup, owners);
+    config.upsertAccessSection(
+        AccessSection.HEADS,
+        heads -> {
+          grant(config, heads, codeReviewLabel, -2, 2, adminsGroup, owners);
+          grant(config, heads, Permission.CREATE, adminsGroup, owners);
+          grant(config, heads, Permission.PUSH, adminsGroup, owners);
+          grant(config, heads, Permission.SUBMIT, adminsGroup, owners);
+          grant(config, heads, Permission.FORGE_COMMITTER, adminsGroup, owners);
+          grant(config, heads, Permission.EDIT_TOPIC_NAME, true, adminsGroup, owners);
+        });
 
-    meta.getPermission(Permission.READ, true).setExclusiveGroup(true);
-    grant(config, meta, Permission.READ, adminsGroup, owners);
-    grant(config, meta, codeReviewLabel, -2, 2, adminsGroup, owners);
-    grant(config, meta, Permission.CREATE, adminsGroup, owners);
-    grant(config, meta, Permission.PUSH, adminsGroup, owners);
-    grant(config, meta, Permission.SUBMIT, adminsGroup, owners);
+    config.upsertAccessSection(
+        "refs/tags/*",
+        tags -> {
+          grant(config, tags, Permission.CREATE, adminsGroup, owners);
+          grant(config, tags, Permission.CREATE_TAG, adminsGroup, owners);
+          grant(config, tags, Permission.CREATE_SIGNED_TAG, adminsGroup, owners);
+        });
+
+    config.upsertAccessSection(
+        RefNames.REFS_CONFIG,
+        meta -> {
+          meta.upsertPermission(Permission.READ).setExclusiveGroup(true);
+          grant(config, meta, Permission.READ, adminsGroup, owners);
+          grant(config, meta, codeReviewLabel, -2, 2, adminsGroup, owners);
+          grant(config, meta, Permission.CREATE, adminsGroup, owners);
+          grant(config, meta, Permission.PUSH, adminsGroup, owners);
+          grant(config, meta, Permission.SUBMIT, adminsGroup, owners);
+        });
   }
 
   private void initSequences(Repository git, BatchRefUpdate bru, int firstChangeId)
diff --git a/java/com/google/gerrit/server/schema/AllUsersCreator.java b/java/com/google/gerrit/server/schema/AllUsersCreator.java
index 10d7070..1ac8e69 100644
--- a/java/com/google/gerrit/server/schema/AllUsersCreator.java
+++ b/java/com/google/gerrit/server/schema/AllUsersCreator.java
@@ -22,7 +22,6 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.common.Version;
-import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.Permission;
@@ -113,33 +112,39 @@
       ProjectConfig config = projectConfigFactory.read(md);
       config.updateProject(p -> p.setDescription("Individual user settings and preferences."));
 
-      AccessSection users =
-          config.getAccessSection(
-              RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}", true);
+      config.upsertAccessSection(
+          RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}",
+          users -> {
+            grant(config, users, Permission.READ, false, true, registered);
+            grant(config, users, Permission.PUSH, false, true, registered);
+            grant(config, users, Permission.SUBMIT, false, true, registered);
+            grant(config, users, codeReviewLabel, -2, 2, true, registered);
+          });
 
       // Initialize "Code-Review" label.
       config.upsertLabelType(codeReviewLabel);
 
-      grant(config, users, Permission.READ, false, true, registered);
-      grant(config, users, Permission.PUSH, false, true, registered);
-      grant(config, users, Permission.SUBMIT, false, true, registered);
-      grant(config, users, codeReviewLabel, -2, 2, true, registered);
-
       if (admin != null) {
-        AccessSection defaults = config.getAccessSection(RefNames.REFS_USERS_DEFAULT, true);
-        defaults.getPermission(Permission.READ, true).setExclusiveGroup(true);
-        grant(config, defaults, Permission.READ, admin);
-        defaults.getPermission(Permission.PUSH, true).setExclusiveGroup(true);
-        grant(config, defaults, Permission.PUSH, admin);
-        defaults.getPermission(Permission.CREATE, true).setExclusiveGroup(true);
-        grant(config, defaults, Permission.CREATE, admin);
+        config.upsertAccessSection(
+            RefNames.REFS_USERS_DEFAULT,
+            defaults -> {
+              defaults.upsertPermission(Permission.READ).setExclusiveGroup(true);
+              grant(config, defaults, Permission.READ, admin);
+              defaults.upsertPermission(Permission.PUSH).setExclusiveGroup(true);
+              grant(config, defaults, Permission.PUSH, admin);
+              defaults.upsertPermission(Permission.CREATE).setExclusiveGroup(true);
+              grant(config, defaults, Permission.CREATE, admin);
+            });
       }
 
       // Grant read permissions on the group branches to all users.
       // This allows group owners to see the group refs. VisibleRefFilter ensures that read
       // permissions for non-group-owners are ignored.
-      AccessSection groups = config.getAccessSection(RefNames.REFS_GROUPS + "*", true);
-      grant(config, groups, Permission.READ, false, true, registered);
+      config.upsertAccessSection(
+          RefNames.REFS_GROUPS + "*",
+          groups -> {
+            grant(config, groups, Permission.READ, false, true, registered);
+          });
 
       config.commit(md);
     }
diff --git a/java/com/google/gerrit/server/schema/GrantRevertPermission.java b/java/com/google/gerrit/server/schema/GrantRevertPermission.java
index 2f890d5..d4ba29b 100644
--- a/java/com/google/gerrit/server/schema/GrantRevertPermission.java
+++ b/java/com/google/gerrit/server/schema/GrantRevertPermission.java
@@ -30,6 +30,7 @@
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.inject.Inject;
 import java.io.IOException;
+import java.util.concurrent.atomic.AtomicBoolean;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Repository;
@@ -63,28 +64,41 @@
     try (Repository repo = repoManager.openRepository(projectName)) {
       MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, projectName, repo);
       ProjectConfig projectConfig = projectConfigFactory.read(md);
-      AccessSection heads = projectConfig.getAccessSection(AccessSection.HEADS, true);
 
-      Permission permissionOnRefsHeads = heads.getPermission(Permission.REVERT);
+      AtomicBoolean shouldExit = new AtomicBoolean(false);
+      projectConfig.upsertAccessSection(
+          AccessSection.HEADS,
+          heads -> {
+            Permission permissionOnRefsHeads = heads.build().getPermission(Permission.REVERT);
 
-      if (permissionOnRefsHeads != null) {
-        if (permissionOnRefsHeads.getRule(registeredUsers) == null
-            || permissionOnRefsHeads.getRules().size() > 1) {
-          // If admins already changed the permission, don't do anything.
-          return;
-        }
-        // permission already exists in refs/heads/*, delete it for Registered Users.
-        remove(projectConfig, heads, Permission.REVERT, registeredUsers);
-      }
+            if (permissionOnRefsHeads != null) {
+              if (permissionOnRefsHeads.getRule(registeredUsers) == null
+                  || permissionOnRefsHeads.getRules().size() > 1) {
+                // If admins already changed the permission, don't do anything.
+                shouldExit.set(true);
+                return;
+              }
+              // permission already exists in refs/heads/*, delete it for Registered Users.
+              remove(projectConfig, heads, Permission.REVERT, registeredUsers);
+            }
+          });
 
-      AccessSection all = projectConfig.getAccessSection(AccessSection.ALL, true);
-      Permission permissionOnRefsStar = all.getPermission(Permission.REVERT);
-      if (permissionOnRefsStar != null && permissionOnRefsStar.getRule(registeredUsers) != null) {
-        // permission already exists in refs/*, don't do anything.
+      if (shouldExit.get()) {
         return;
       }
-      // If the permission doesn't exist of refs/* for Registered Users, grant it.
-      grant(projectConfig, all, Permission.REVERT, registeredUsers);
+
+      projectConfig.upsertAccessSection(
+          AccessSection.ALL,
+          all -> {
+            Permission permissionOnRefsStar = all.build().getPermission(Permission.REVERT);
+            if (permissionOnRefsStar != null
+                && permissionOnRefsStar.getRule(registeredUsers) != null) {
+              // permission already exists in refs/*, don't do anything.
+              return;
+            }
+            // If the permission doesn't exist of refs/* for Registered Users, grant it.
+            grant(projectConfig, all, Permission.REVERT, registeredUsers);
+          });
 
       md.getCommitBuilder().setAuthor(serverUser);
       md.getCommitBuilder().setCommitter(serverUser);
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
index 3c1bc2f..aaa1eaa 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
@@ -139,7 +139,6 @@
 import com.google.gerrit.server.index.account.StalenessChecker;
 import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
-import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.RefPattern;
 import com.google.gerrit.server.query.account.InternalAccountQuery;
 import com.google.gerrit.server.update.RetryHelper;
@@ -284,12 +283,16 @@
       String labelName,
       int min,
       int max) {
-    ProjectConfig cfg = projectCache.get(project).orElseThrow(illegalState(project)).getConfig();
-    AccessSection accessSection = cfg.getAccessSection(ref);
-    assertThat(accessSection).isNotNull();
+    Optional<AccessSection> accessSection =
+        projectCache
+            .get(project)
+            .orElseThrow(illegalState(project))
+            .getConfig()
+            .getAccessSection(ref);
+    assertThat(accessSection).isPresent();
 
     String permissionName = Permission.LABEL + labelName;
-    Permission permission = accessSection.getPermission(permissionName);
+    Permission permission = accessSection.get().getPermission(permissionName);
     assertPermission(permission, permissionName, exclusive, labelName);
     assertPermissionRule(
         permission.getRule(groupReference), groupReference, Action.ALLOW, false, min, max);
@@ -1653,8 +1656,11 @@
       // remove default READ permissions
       try (ProjectConfigUpdate u = updateProject(allUsers)) {
         u.getConfig()
-            .getAccessSection(RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}", true)
-            .remove(new Permission(Permission.READ));
+            .upsertAccessSection(
+                RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}",
+                as -> {
+                  as.remove(Permission.builder(Permission.READ));
+                });
         u.save();
       }
 
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
index 23fdc0b..62a1ad2 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
@@ -18,6 +18,7 @@
 import static com.google.common.truth.TruthJUnit.assume;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.Comparator.comparing;
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -125,8 +126,11 @@
     if (isContributorAgreementsEnabled()) {
       assertThat(info.auth.useContributorAgreements).isTrue();
       assertThat(info.auth.contributorAgreements).hasSize(2);
-      assertAgreement(info.auth.contributorAgreements.get(0), caAutoVerify);
-      assertAgreement(info.auth.contributorAgreements.get(1), caNoAutoVerify);
+      // Sort to get a stable assertion as the API does not guarantee ordering.
+      List<AgreementInfo> agreements =
+          ImmutableList.sortedCopyOf(comparing(a -> a.name), info.auth.contributorAgreements);
+      assertAgreement(agreements.get(0), caAutoVerify);
+      assertAgreement(agreements.get(1), caNoAutoVerify);
     } else {
       assertThat(info.auth.useContributorAgreements).isNull();
       assertThat(info.auth.contributorAgreements).isNull();
diff --git a/javatests/com/google/gerrit/acceptance/api/change/BUILD b/javatests/com/google/gerrit/acceptance/api/change/BUILD
index 9279488..f5993a4 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/BUILD
+++ b/javatests/com/google/gerrit/acceptance/api/change/BUILD
@@ -1,11 +1,11 @@
 load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
 
-acceptance_tests(
-    srcs = glob(["*IT.java"]),
-    group = "api_change",
+[acceptance_tests(
+    srcs = [f],
+    group = f[:f.index(".")],
     labels = [
         "api",
         "noci",
     ],
     deps = ["//java/com/google/gerrit/server/util/time"],
-)
+) for f in glob(["*IT.java"])]
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/BUILD b/javatests/com/google/gerrit/acceptance/api/revision/BUILD
index 06e45c5..3bfe2f0 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/BUILD
+++ b/javatests/com/google/gerrit/acceptance/api/revision/BUILD
@@ -1,7 +1,7 @@
 load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
 
-acceptance_tests(
-    srcs = glob(["*IT.java"]),
-    group = "api_revision",
+[acceptance_tests(
+    srcs = [f],
+    group = f[:f.index(".")],
     labels = ["api"],
-)
+) for f in glob(["*IT.java"])]
diff --git a/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java b/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
index 98b93a8..b1c07ad 100644
--- a/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
@@ -362,22 +362,28 @@
   }
 
   private static void removeAllBranchPermissions(ProjectConfig cfg, String... permissions) {
-    cfg.getAccessSections().stream()
-        .filter(
-            s ->
-                s.getName().startsWith("refs/heads/")
-                    || s.getName().startsWith("refs/for/")
-                    || s.getName().equals("refs/*"))
-        .forEach(s -> Arrays.stream(permissions).forEach(s::removePermission));
+    for (AccessSection s : ImmutableList.copyOf(cfg.getAccessSections())) {
+      if (s.getName().startsWith("refs/heads/")
+          || s.getName().startsWith("refs/for/")
+          || s.getName().equals("refs/*")) {
+        cfg.upsertAccessSection(
+            s.getName(),
+            updatedSection -> {
+              Arrays.stream(permissions).forEach(p -> updatedSection.remove(Permission.builder(p)));
+            });
+      }
+    }
   }
 
   private static void removeAllGlobalCapabilities(ProjectConfig cfg, String... capabilities) {
     Arrays.stream(capabilities)
         .forEach(
             c ->
-                cfg.getAccessSection(AccessSection.GLOBAL_CAPABILITIES, true)
-                    .getPermission(c, true)
-                    .clearRules());
+                cfg.upsertAccessSection(
+                    AccessSection.GLOBAL_CAPABILITIES,
+                    as -> {
+                      as.upsertPermission(c).clearRules();
+                    }));
   }
 
   private PushResult push(String... refSpecs) throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
index 14288b5..8b9d173 100644
--- a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
@@ -127,8 +127,14 @@
   private void setUpPermissions() throws Exception {
     // Remove read permissions for all users besides admin.
     try (ProjectConfigUpdate u = updateProject(allProjects)) {
-      for (AccessSection sec : u.getConfig().getAccessSections()) {
-        sec.removePermission(Permission.READ);
+
+      for (AccessSection sec : ImmutableList.copyOf(u.getConfig().getAccessSections())) {
+        u.getConfig()
+            .upsertAccessSection(
+                sec.getName(),
+                updatedSec -> {
+                  updatedSec.removePermission(Permission.READ);
+                });
       }
       u.save();
     }
@@ -139,8 +145,13 @@
 
     // Remove all read permissions on All-Users.
     try (ProjectConfigUpdate u = updateProject(allUsers)) {
-      for (AccessSection sec : u.getConfig().getAccessSections()) {
-        sec.removePermission(Permission.READ);
+      for (AccessSection sec : ImmutableList.copyOf(u.getConfig().getAccessSections())) {
+        u.getConfig()
+            .upsertAccessSection(
+                sec.getName(),
+                updatedSec -> {
+                  updatedSec.removePermission(Permission.READ);
+                });
       }
       u.save();
     }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java b/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java
index 0514e03..46054ec 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java
@@ -120,8 +120,11 @@
     try (Repository repo = repoManager.openRepository(newProjectName)) {
       MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, newProjectName, repo);
       ProjectConfig projectConfig = projectConfigFactory.read(md);
-      AccessSection heads = projectConfig.getAccessSection(AccessSection.HEADS, true);
-      grant(projectConfig, heads, Permission.REVERT, registeredUsers);
+      projectConfig.upsertAccessSection(
+          AccessSection.HEADS,
+          heads -> {
+            grant(projectConfig, heads, Permission.REVERT, registeredUsers);
+          });
       md.getCommitBuilder().setAuthor(admin.newIdent());
       md.getCommitBuilder().setCommitter(admin.newIdent());
       md.setMessage("Add revert permission for all registered users\n");
@@ -155,15 +158,19 @@
     try (Repository repo = repoManager.openRepository(newProjectName)) {
       MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, newProjectName, repo);
       ProjectConfig projectConfig = projectConfigFactory.read(md);
-      AccessSection heads = projectConfig.getAccessSection(AccessSection.HEADS, true);
-      grant(projectConfig, heads, Permission.REVERT, registeredUsers);
-      grant(projectConfig, heads, Permission.REVERT, otherGroup);
+      projectConfig.upsertAccessSection(
+          AccessSection.HEADS,
+          heads -> {
+            grant(projectConfig, heads, Permission.REVERT, registeredUsers);
+            grant(projectConfig, heads, Permission.REVERT, otherGroup);
+          });
       md.getCommitBuilder().setAuthor(admin.newIdent());
       md.getCommitBuilder().setCommitter(admin.newIdent());
       md.setMessage("Add revert permission for all registered users\n");
 
       projectConfig.commit(md);
     }
+    projectCache.evict(newProjectName);
     ProjectAccessInfo expected = pApi().access();
 
     grantRevertPermission.execute(newProjectName);
@@ -181,7 +188,7 @@
     try (Repository repo = repoManager.openRepository(newProjectName)) {
       MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, newProjectName, repo);
       ProjectConfig projectConfig = projectConfigFactory.read(md);
-      AccessSection all = projectConfig.getAccessSection(AccessSection.ALL, true);
+      AccessSection all = projectConfig.getAccessSection(AccessSection.ALL);
 
       Permission permission = all.getPermission(Permission.REVERT);
       assertThat(permission.getRules()).hasSize(1);
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/GetCommitIT.java b/javatests/com/google/gerrit/acceptance/rest/project/GetCommitIT.java
index b18db81..1b1a36d 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/GetCommitIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/GetCommitIT.java
@@ -129,7 +129,12 @@
 
   private void unblockRead() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().getAccessSection("refs/*").remove(new Permission(Permission.READ));
+      u.getConfig()
+          .upsertAccessSection(
+              "refs/*",
+              as -> {
+                as.remove(Permission.builder(Permission.READ));
+              });
       u.save();
     }
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java
index f5d2db4..3d3865a 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java
@@ -28,6 +28,7 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.projects.ProjectApi.ListRefsRequest;
 import com.google.gerrit.extensions.api.projects.TagApi;
@@ -158,6 +159,12 @@
   @Test
   public void listTagsOfNonVisibleBranch() throws Exception {
     grantTagPermissions();
+    // Allow creating a new hidden branch
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.CREATE).group(REGISTERED_USERS).ref("refs/heads/hidden"))
+        .update();
 
     PushOneCommit push1 = pushFactory.create(admin.newIdent(), testRepo);
     PushOneCommit.Result r1 = push1.to("refs/heads/master");
@@ -169,7 +176,7 @@
     assertThat(result.ref).isEqualTo(R_TAGS + tag1.ref);
     assertThat(result.revision).isEqualTo(tag1.revision);
 
-    pushTo("refs/heads/hidden");
+    pushTo("refs/heads/hidden").assertOkStatus();
     PushOneCommit push2 = pushFactory.create(admin.newIdent(), testRepo);
     PushOneCommit.Result r2 = push2.to("refs/heads/hidden");
     r2.assertOkStatus();
@@ -470,8 +477,12 @@
   }
 
   private static void removeAllBranchPermissions(ProjectConfig cfg, String... permissions) {
-    cfg.getAccessSections().stream()
-        .filter(s -> s.getName().startsWith("refs/tags/"))
-        .forEach(s -> Arrays.stream(permissions).forEach(s::removePermission));
+    for (AccessSection accessSection : ImmutableList.copyOf(cfg.getAccessSections())) {
+      cfg.upsertAccessSection(
+          accessSection.getName(),
+          updatedAccessSection -> {
+            Arrays.stream(permissions).forEach(updatedAccessSection::removePermission);
+          });
+    }
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/server/project/CustomLabelIT.java b/javatests/com/google/gerrit/acceptance/server/project/CustomLabelIT.java
index 813a715..1991e79 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/CustomLabelIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/CustomLabelIT.java
@@ -47,7 +47,7 @@
 import com.google.gerrit.extensions.common.LabelInfo;
 import com.google.gerrit.extensions.events.CommentAddedListener;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.server.project.CachedProjectConfig;
 import com.google.inject.Inject;
 import org.junit.Before;
 import org.junit.Test;
@@ -322,7 +322,8 @@
   @Test
   public void customLabel_withBranch() throws Exception {
     saveLabelConfig(LABEL.toBuilder().setRefPatterns(ImmutableList.of("master")));
-    ProjectConfig cfg = projectCache.get(project).orElseThrow(illegalState(project)).getConfig();
+    CachedProjectConfig cfg =
+        projectCache.get(project).orElseThrow(illegalState(project)).getConfig();
     assertThat(cfg.getLabelSections().get(LABEL_NAME).getRefPatterns()).contains("master");
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java b/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java
index c8899b9..3e8c017 100644
--- a/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java
+++ b/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java
@@ -102,14 +102,14 @@
     Project.NameKey key = projectOperations.newProject().create();
     ProjectConfig projectConfig = projectOperations.project(key).getProjectConfig();
     ProjectState cachedProjectState1 = projectCache.get(key).orElseThrow(illegalState(project));
-    ProjectConfig cachedProjectConfig1 = cachedProjectState1.getConfig();
+    ProjectConfig cachedProjectConfig1 = cachedProjectState1.getBareConfig();
     assertThat(cachedProjectConfig1).isNotSameInstanceAs(projectConfig);
     assertThat(cachedProjectConfig1.getProject().getDescription()).isEmpty();
     assertThat(projectConfig.getProject().getDescription()).isEmpty();
     projectConfig.updateProject(p -> p.setDescription("my fancy project"));
 
     ProjectConfig cachedProjectConfig2 =
-        projectCache.get(key).orElseThrow(illegalState(project)).getConfig();
+        projectCache.get(key).orElseThrow(illegalState(project)).getBareConfig();
     assertThat(cachedProjectConfig2).isNotSameInstanceAs(projectConfig);
     assertThat(cachedProjectConfig2.getProject().getDescription()).isEmpty();
   }
diff --git a/javatests/com/google/gerrit/common/data/AccessSectionTest.java b/javatests/com/google/gerrit/common/data/AccessSectionTest.java
index e775cbc..f60156c 100644
--- a/javatests/com/google/gerrit/common/data/AccessSectionTest.java
+++ b/javatests/com/google/gerrit/common/data/AccessSectionTest.java
@@ -17,9 +17,6 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
-import com.google.common.collect.ImmutableList;
-import java.util.ArrayList;
-import java.util.List;
 import java.util.Locale;
 import org.junit.Before;
 import org.junit.Test;
@@ -27,11 +24,11 @@
 public class AccessSectionTest {
   private static final String REF_PATTERN = "refs/heads/master";
 
-  private AccessSection accessSection;
+  private AccessSection.Builder accessSection;
 
   @Before
   public void setup() {
-    this.accessSection = new AccessSection(REF_PATTERN);
+    this.accessSection = AccessSection.builder(REF_PATTERN);
   }
 
   @Test
@@ -41,22 +38,36 @@
 
   @Test
   public void getEmptyPermissions() {
-    assertThat(accessSection.getPermissions()).isNotNull();
-    assertThat(accessSection.getPermissions()).isEmpty();
+    AccessSection builtAccessSection = accessSection.build();
+    assertThat(builtAccessSection.getPermissions()).isNotNull();
+    assertThat(builtAccessSection.getPermissions()).isEmpty();
   }
 
   @Test
   public void setAndGetPermissions() {
-    Permission abandonPermission = new Permission(Permission.ABANDON);
-    Permission rebasePermission = new Permission(Permission.REBASE);
-    accessSection.setPermissions(ImmutableList.of(abandonPermission, rebasePermission));
-    assertThat(accessSection.getPermissions())
-        .containsExactly(abandonPermission, rebasePermission)
-        .inOrder();
+    Permission.Builder abandonPermission = Permission.builder(Permission.ABANDON);
+    Permission.Builder rebasePermission = Permission.builder(Permission.REBASE);
+    accessSection.modifyPermissions(
+        permissions -> {
+          permissions.clear();
+          permissions.add(abandonPermission);
+          permissions.add(rebasePermission);
+        });
 
-    Permission submitPermission = new Permission(Permission.SUBMIT);
-    accessSection.setPermissions(ImmutableList.of(submitPermission));
-    assertThat(accessSection.getPermissions()).containsExactly(submitPermission);
+    AccessSection builtAccessSection = accessSection.build();
+    assertThat(builtAccessSection.getPermissions()).hasSize(2);
+    assertThat(builtAccessSection.getPermission(abandonPermission.getName())).isNotNull();
+    assertThat(builtAccessSection.getPermission(rebasePermission.getName())).isNotNull();
+
+    Permission.Builder submitPermission = Permission.builder(Permission.SUBMIT);
+    accessSection.modifyPermissions(
+        p -> {
+          p.clear();
+          p.add(submitPermission);
+        });
+    builtAccessSection = accessSection.build();
+    assertThat(builtAccessSection.getPermissions()).hasSize(1);
+    assertThat(builtAccessSection.getPermission(submitPermission.getName())).isNotNull();
     assertThrows(NullPointerException.class, () -> accessSection.setPermissions(null));
   }
 
@@ -65,122 +76,117 @@
     assertThrows(
         IllegalArgumentException.class,
         () ->
-            accessSection.setPermissions(
-                ImmutableList.of(
-                    new Permission(Permission.ABANDON), new Permission(Permission.ABANDON))));
+            accessSection
+                .addPermission(Permission.builder(Permission.ABANDON))
+                .addPermission(Permission.builder(Permission.ABANDON))
+                .build());
   }
 
   @Test
   public void cannotSetPermissionsWithConflictingNames() {
-    Permission abandonPermissionLowerCase =
-        new Permission(Permission.ABANDON.toLowerCase(Locale.US));
-    Permission abandonPermissionUpperCase =
-        new Permission(Permission.ABANDON.toUpperCase(Locale.US));
+    Permission.Builder abandonPermissionLowerCase =
+        Permission.builder(Permission.ABANDON.toLowerCase(Locale.US));
+    Permission.Builder abandonPermissionUpperCase =
+        Permission.builder(Permission.ABANDON.toUpperCase(Locale.US));
 
     assertThrows(
         IllegalArgumentException.class,
         () ->
-            accessSection.setPermissions(
-                ImmutableList.of(abandonPermissionLowerCase, abandonPermissionUpperCase)));
+            accessSection
+                .addPermission(abandonPermissionLowerCase)
+                .addPermission(abandonPermissionUpperCase)
+                .build());
   }
 
   @Test
   public void getNonExistingPermission() {
-    assertThat(accessSection.getPermission("non-existing")).isNull();
-    assertThat(accessSection.getPermission("non-existing", false)).isNull();
+    assertThat(accessSection.build().getPermission("non-existing")).isNull();
+    assertThat(accessSection.build().getPermission("non-existing")).isNull();
   }
 
   @Test
   public void getPermission() {
-    Permission submitPermission = new Permission(Permission.SUBMIT);
-    accessSection.setPermissions(ImmutableList.of(submitPermission));
-    assertThat(accessSection.getPermission(Permission.SUBMIT)).isEqualTo(submitPermission);
-    assertThrows(NullPointerException.class, () -> accessSection.getPermission(null));
+    Permission.Builder submitPermission = Permission.builder(Permission.SUBMIT);
+    accessSection.addPermission(submitPermission);
+    assertThat(accessSection.upsertPermission(Permission.SUBMIT)).isEqualTo(submitPermission);
+    assertThrows(NullPointerException.class, () -> accessSection.upsertPermission(null));
   }
 
   @Test
   public void getPermissionWithOtherCase() {
-    Permission submitPermissionLowerCase = new Permission(Permission.SUBMIT.toLowerCase(Locale.US));
-    accessSection.setPermissions(ImmutableList.of(submitPermissionLowerCase));
-    assertThat(accessSection.getPermission(Permission.SUBMIT.toUpperCase(Locale.US)))
+    Permission.Builder submitPermissionLowerCase =
+        Permission.builder(Permission.SUBMIT.toLowerCase(Locale.US));
+    accessSection.addPermission(submitPermissionLowerCase);
+    assertThat(accessSection.upsertPermission(Permission.SUBMIT.toUpperCase(Locale.US)))
         .isEqualTo(submitPermissionLowerCase);
   }
 
   @Test
   public void createMissingPermissionOnGet() {
-    assertThat(accessSection.getPermission(Permission.SUBMIT)).isNull();
+    assertThat(accessSection.build().getPermission(Permission.SUBMIT)).isNull();
 
-    assertThat(accessSection.getPermission(Permission.SUBMIT, true))
-        .isEqualTo(new Permission(Permission.SUBMIT));
+    assertThat(accessSection.upsertPermission(Permission.SUBMIT).build())
+        .isEqualTo(Permission.create(Permission.SUBMIT));
 
-    assertThrows(NullPointerException.class, () -> accessSection.getPermission(null, true));
+    assertThrows(NullPointerException.class, () -> accessSection.upsertPermission(null));
   }
 
   @Test
   public void addPermission() {
-    Permission abandonPermission = new Permission(Permission.ABANDON);
-    Permission rebasePermission = new Permission(Permission.REBASE);
+    Permission.Builder abandonPermission = Permission.builder(Permission.ABANDON);
+    Permission.Builder rebasePermission = Permission.builder(Permission.REBASE);
 
-    accessSection.setPermissions(ImmutableList.of(abandonPermission, rebasePermission));
-    assertThat(accessSection.getPermission(Permission.SUBMIT)).isNull();
+    accessSection.addPermission(abandonPermission);
+    accessSection.addPermission(rebasePermission);
+    assertThat(accessSection.build().getPermission(Permission.SUBMIT)).isNull();
 
-    Permission submitPermission = new Permission(Permission.SUBMIT);
+    Permission.Builder submitPermission = Permission.builder(Permission.SUBMIT);
     accessSection.addPermission(submitPermission);
-    assertThat(accessSection.getPermission(Permission.SUBMIT)).isEqualTo(submitPermission);
-    assertThat(accessSection.getPermissions())
-        .containsExactly(abandonPermission, rebasePermission, submitPermission)
+    assertThat(accessSection.build().getPermission(Permission.SUBMIT))
+        .isEqualTo(submitPermission.build());
+    assertThat(accessSection.build().getPermissions())
+        .containsExactly(
+            abandonPermission.build(), rebasePermission.build(), submitPermission.build())
         .inOrder();
     assertThrows(NullPointerException.class, () -> accessSection.addPermission(null));
   }
 
   @Test
-  public void cannotAddPermissionByModifyingListThatWasProvidedToAccessSection() {
-    Permission abandonPermission = new Permission(Permission.ABANDON);
-    Permission rebasePermission = new Permission(Permission.REBASE);
-
-    List<Permission> permissions = new ArrayList<>();
-    permissions.add(abandonPermission);
-    permissions.add(rebasePermission);
-    accessSection.setPermissions(permissions);
-    assertThat(accessSection.getPermission(Permission.SUBMIT)).isNull();
-
-    Permission submitPermission = new Permission(Permission.SUBMIT);
-    permissions.add(submitPermission);
-    assertThat(accessSection.getPermission(Permission.SUBMIT)).isNull();
-  }
-
-  @Test
   public void removePermission() {
-    Permission abandonPermission = new Permission(Permission.ABANDON);
-    Permission rebasePermission = new Permission(Permission.REBASE);
-    Permission submitPermission = new Permission(Permission.SUBMIT);
+    Permission.Builder abandonPermission = Permission.builder(Permission.ABANDON);
+    Permission.Builder rebasePermission = Permission.builder(Permission.REBASE);
+    Permission.Builder submitPermission = Permission.builder(Permission.SUBMIT);
 
-    accessSection.setPermissions(
-        ImmutableList.of(abandonPermission, rebasePermission, submitPermission));
-    assertThat(accessSection.getPermission(Permission.SUBMIT)).isNotNull();
+    accessSection.addPermission(abandonPermission);
+    accessSection.addPermission(rebasePermission);
+    accessSection.addPermission(submitPermission);
+    assertThat(accessSection.build().getPermission(Permission.SUBMIT)).isNotNull();
 
     accessSection.remove(submitPermission);
-    assertThat(accessSection.getPermission(Permission.SUBMIT)).isNull();
-    assertThat(accessSection.getPermissions())
-        .containsExactly(abandonPermission, rebasePermission)
+    assertThat(accessSection.build().getPermission(Permission.SUBMIT)).isNull();
+    assertThat(accessSection.build().getPermissions())
+        .containsExactly(abandonPermission.build(), rebasePermission.build())
         .inOrder();
     assertThrows(NullPointerException.class, () -> accessSection.remove(null));
   }
 
   @Test
   public void removePermissionByName() {
-    Permission abandonPermission = new Permission(Permission.ABANDON);
-    Permission rebasePermission = new Permission(Permission.REBASE);
-    Permission submitPermission = new Permission(Permission.SUBMIT);
+    Permission.Builder abandonPermission = Permission.builder(Permission.ABANDON);
+    Permission.Builder rebasePermission = Permission.builder(Permission.REBASE);
+    Permission.Builder submitPermission = Permission.builder(Permission.SUBMIT);
 
-    accessSection.setPermissions(
-        ImmutableList.of(abandonPermission, rebasePermission, submitPermission));
-    assertThat(accessSection.getPermission(Permission.SUBMIT)).isNotNull();
+    accessSection.addPermission(abandonPermission);
+    accessSection.addPermission(rebasePermission);
+    accessSection.addPermission(submitPermission);
+    AccessSection builtAccessSection = accessSection.build();
+    assertThat(builtAccessSection.getPermission(Permission.SUBMIT)).isNotNull();
 
     accessSection.removePermission(Permission.SUBMIT);
-    assertThat(accessSection.getPermission(Permission.SUBMIT)).isNull();
-    assertThat(accessSection.getPermissions())
-        .containsExactly(abandonPermission, rebasePermission)
+    builtAccessSection = accessSection.build();
+    assertThat(builtAccessSection.getPermission(Permission.SUBMIT)).isNull();
+    assertThat(builtAccessSection.getPermissions())
+        .containsExactly(abandonPermission.build(), rebasePermission.build())
         .inOrder();
 
     assertThrows(NullPointerException.class, () -> accessSection.removePermission(null));
@@ -188,62 +194,49 @@
 
   @Test
   public void removePermissionByNameOtherCase() {
-    Permission abandonPermission = new Permission(Permission.ABANDON);
-    Permission rebasePermission = new Permission(Permission.REBASE);
+    Permission.Builder abandonPermission = Permission.builder(Permission.ABANDON);
+    Permission.Builder rebasePermission = Permission.builder(Permission.REBASE);
 
     String submitLowerCase = Permission.SUBMIT.toLowerCase(Locale.US);
     String submitUpperCase = Permission.SUBMIT.toUpperCase(Locale.US);
-    Permission submitPermissionLowerCase = new Permission(submitLowerCase);
+    Permission.Builder submitPermissionLowerCase = Permission.builder(submitLowerCase);
 
-    accessSection.setPermissions(
-        ImmutableList.of(abandonPermission, rebasePermission, submitPermissionLowerCase));
-    assertThat(accessSection.getPermission(submitLowerCase)).isNotNull();
-    assertThat(accessSection.getPermission(submitUpperCase)).isNotNull();
+    accessSection.addPermission(abandonPermission);
+    accessSection.addPermission(rebasePermission);
+    accessSection.addPermission(submitPermissionLowerCase);
+    AccessSection builtAccessSection = accessSection.build();
+    assertThat(builtAccessSection.getPermission(submitLowerCase)).isNotNull();
+    assertThat(builtAccessSection.getPermission(submitUpperCase)).isNotNull();
 
     accessSection.removePermission(submitUpperCase);
-    assertThat(accessSection.getPermission(submitLowerCase)).isNull();
-    assertThat(accessSection.getPermission(submitUpperCase)).isNull();
-    assertThat(accessSection.getPermissions())
-        .containsExactly(abandonPermission, rebasePermission)
+    builtAccessSection = accessSection.build();
+    assertThat(builtAccessSection.getPermission(submitLowerCase)).isNull();
+    assertThat(builtAccessSection.getPermission(submitUpperCase)).isNull();
+    assertThat(builtAccessSection.getPermissions())
+        .containsExactly(abandonPermission.build(), rebasePermission.build())
         .inOrder();
   }
 
   @Test
-  public void mergeAccessSections() {
-    Permission abandonPermission = new Permission(Permission.ABANDON);
-    Permission rebasePermission = new Permission(Permission.REBASE);
-    Permission submitPermission = new Permission(Permission.SUBMIT);
-
-    AccessSection accessSection1 = new AccessSection("refs/heads/foo");
-    accessSection1.setPermissions(ImmutableList.of(abandonPermission, rebasePermission));
-
-    AccessSection accessSection2 = new AccessSection("refs/heads/bar");
-    accessSection2.setPermissions(ImmutableList.of(rebasePermission, submitPermission));
-
-    accessSection1.mergeFrom(accessSection2);
-    assertThat(accessSection1.getPermissions())
-        .containsExactly(abandonPermission, rebasePermission, submitPermission)
-        .inOrder();
-    assertThrows(NullPointerException.class, () -> accessSection.mergeFrom(null));
-  }
-
-  @Test
   public void testEquals() {
-    Permission abandonPermission = new Permission(Permission.ABANDON);
-    Permission rebasePermission = new Permission(Permission.REBASE);
+    Permission.Builder abandonPermission = Permission.builder(Permission.ABANDON);
+    Permission.Builder rebasePermission = Permission.builder(Permission.REBASE);
 
-    accessSection.setPermissions(ImmutableList.of(abandonPermission, rebasePermission));
+    accessSection.addPermission(abandonPermission);
+    accessSection.addPermission(rebasePermission);
 
-    AccessSection accessSectionSamePermissionsOtherRef = new AccessSection("refs/heads/other");
-    accessSectionSamePermissionsOtherRef.setPermissions(
-        ImmutableList.of(abandonPermission, rebasePermission));
-    assertThat(accessSection.equals(accessSectionSamePermissionsOtherRef)).isFalse();
+    AccessSection builtAccessSection = accessSection.build();
+    AccessSection.Builder accessSectionSamePermissionsOtherRef =
+        AccessSection.builder("refs/heads/other");
+    accessSectionSamePermissionsOtherRef.addPermission(abandonPermission);
+    accessSectionSamePermissionsOtherRef.addPermission(rebasePermission);
+    assertThat(builtAccessSection.equals(accessSectionSamePermissionsOtherRef.build())).isFalse();
 
-    AccessSection accessSectionOther = new AccessSection(REF_PATTERN);
-    accessSectionOther.setPermissions(ImmutableList.of(abandonPermission));
-    assertThat(accessSection.equals(accessSectionOther)).isFalse();
+    AccessSection.Builder accessSectionOther = AccessSection.builder(REF_PATTERN);
+    accessSectionOther.addPermission(abandonPermission);
+    assertThat(builtAccessSection.equals(accessSectionOther.build())).isFalse();
 
     accessSectionOther.addPermission(rebasePermission);
-    assertThat(accessSection.equals(accessSectionOther)).isTrue();
+    assertThat(builtAccessSection.equals(accessSectionOther.build())).isTrue();
   }
 }
diff --git a/javatests/com/google/gerrit/common/data/LabelTypeTest.java b/javatests/com/google/gerrit/common/data/LabelTypeTest.java
index 76ea6e1..2f81fa9 100644
--- a/javatests/com/google/gerrit/common/data/LabelTypeTest.java
+++ b/javatests/com/google/gerrit/common/data/LabelTypeTest.java
@@ -30,6 +30,18 @@
   }
 
   @Test
+  public void sortCopyValues() {
+    LabelValue v0 = LabelValue.create((short) 0, "Zero");
+    LabelValue v1 = LabelValue.create((short) 1, "One");
+    LabelValue v2 = LabelValue.create((short) 2, "Two");
+    LabelType types =
+        LabelType.builder("Label", ImmutableList.of(v2, v0, v1))
+            .setCopyValues(ImmutableList.of((short) 2, (short) 0, (short) 1))
+            .build();
+    assertThat(types.getCopyValues()).containsExactly((short) 0, (short) 1, (short) 2).inOrder();
+  }
+
+  @Test
   public void insertMissingLabelValues() {
     LabelValue v0 = LabelValue.create((short) 0, "Zero");
     LabelValue v2 = LabelValue.create((short) 2, "Two");
diff --git a/javatests/com/google/gerrit/common/data/PermissionTest.java b/javatests/com/google/gerrit/common/data/PermissionTest.java
index a363129..9dd71ca 100644
--- a/javatests/com/google/gerrit/common/data/PermissionTest.java
+++ b/javatests/com/google/gerrit/common/data/PermissionTest.java
@@ -16,21 +16,18 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.AccountGroup;
-import java.util.ArrayList;
-import java.util.List;
 import org.junit.Before;
 import org.junit.Test;
 
 public class PermissionTest {
   private static final String PERMISSION_NAME = "foo";
 
-  private Permission permission;
+  private Permission.Builder permission;
 
   @Before
   public void setup() {
-    this.permission = new Permission(PERMISSION_NAME);
+    this.permission = Permission.builder(PERMISSION_NAME);
   }
 
   @Test
@@ -117,209 +114,179 @@
 
   @Test
   public void getLabel() {
-    assertThat(new Permission(Permission.LABEL + "Code-Review").getLabel())
+    assertThat(Permission.create(Permission.LABEL + "Code-Review").getLabel())
         .isEqualTo("Code-Review");
-    assertThat(new Permission(Permission.LABEL_AS + "Code-Review").getLabel())
+    assertThat(Permission.create(Permission.LABEL_AS + "Code-Review").getLabel())
         .isEqualTo("Code-Review");
-    assertThat(new Permission("Code-Review").getLabel()).isNull();
-    assertThat(new Permission(Permission.ABANDON).getLabel()).isNull();
+    assertThat(Permission.create("Code-Review").getLabel()).isNull();
+    assertThat(Permission.create(Permission.ABANDON).getLabel()).isNull();
   }
 
   @Test
   public void exclusiveGroup() {
-    assertThat(permission.getExclusiveGroup()).isFalse();
+    assertThat(permission.build().getExclusiveGroup()).isFalse();
 
     permission.setExclusiveGroup(true);
-    assertThat(permission.getExclusiveGroup()).isTrue();
+    assertThat(permission.build().getExclusiveGroup()).isTrue();
 
     permission.setExclusiveGroup(false);
-    assertThat(permission.getExclusiveGroup()).isFalse();
+    assertThat(permission.build().getExclusiveGroup()).isFalse();
   }
 
   @Test
   public void noExclusiveGroupOnOwnerPermission() {
-    Permission permission = new Permission(Permission.OWNER);
+    Permission permission = Permission.create(Permission.OWNER);
     assertThat(permission.getExclusiveGroup()).isFalse();
 
-    permission.setExclusiveGroup(true);
+    permission = permission.toBuilder().setExclusiveGroup(true).build();
     assertThat(permission.getExclusiveGroup()).isFalse();
   }
 
   @Test
   public void getEmptyRules() {
-    assertThat(permission.getRules()).isNotNull();
-    assertThat(permission.getRules()).isEmpty();
+    assertThat(permission.getRulesBuilders()).isNotNull();
+    assertThat(permission.getRulesBuilders()).isEmpty();
   }
 
   @Test
   public void setAndGetRules() {
-    PermissionRule permissionRule1 =
-        PermissionRule.create(GroupReference.create(AccountGroup.uuid("uuid-1"), "group1"));
-    PermissionRule permissionRule2 =
-        PermissionRule.create(GroupReference.create(AccountGroup.uuid("uuid-2"), "group2"));
-    permission.setRules(ImmutableList.of(permissionRule1, permissionRule2));
-    assertThat(permission.getRules()).containsExactly(permissionRule1, permissionRule2).inOrder();
+    PermissionRule.Builder permissionRule1 =
+        PermissionRule.builder(GroupReference.create(AccountGroup.uuid("uuid-1"), "group1"));
+    PermissionRule.Builder permissionRule2 =
+        PermissionRule.builder(GroupReference.create(AccountGroup.uuid("uuid-2"), "group2"));
+    permission.add(permissionRule1);
+    permission.add(permissionRule2);
+    assertThat(permission.getRulesBuilders())
+        .containsExactly(permissionRule1, permissionRule2)
+        .inOrder();
 
-    PermissionRule permissionRule3 =
-        PermissionRule.create(GroupReference.create(AccountGroup.uuid("uuid-3"), "group3"));
-    permission.setRules(ImmutableList.of(permissionRule3));
-    assertThat(permission.getRules()).containsExactly(permissionRule3);
-  }
-
-  @Test
-  public void cannotAddPermissionByModifyingListThatWasProvidedToAccessSection() {
-    PermissionRule permissionRule1 =
-        PermissionRule.create(GroupReference.create(AccountGroup.uuid("uuid-1"), "group1"));
-    PermissionRule permissionRule2 =
-        PermissionRule.create(GroupReference.create(AccountGroup.uuid("uuid-2"), "group2"));
-    GroupReference groupReference3 = GroupReference.create(AccountGroup.uuid("uuid-3"), "group3");
-
-    List<PermissionRule> rules = new ArrayList<>();
-    rules.add(permissionRule1);
-    rules.add(permissionRule2);
-    permission.setRules(rules);
-    assertThat(permission.getRule(groupReference3)).isNull();
-
-    PermissionRule permissionRule3 = PermissionRule.create(groupReference3);
-    rules.add(permissionRule3);
-    assertThat(permission.getRule(groupReference3)).isNull();
+    PermissionRule.Builder permissionRule3 =
+        PermissionRule.builder(GroupReference.create(AccountGroup.uuid("uuid-3"), "group3"));
+    permission.modifyRules(
+        rules -> {
+          rules.clear();
+          rules.add(permissionRule3);
+        });
+    assertThat(permission.getRulesBuilders()).containsExactly(permissionRule3);
   }
 
   @Test
   public void getNonExistingRule() {
     GroupReference groupReference = GroupReference.create(AccountGroup.uuid("uuid-1"), "group1");
-    assertThat(permission.getRule(groupReference)).isNull();
-    assertThat(permission.getRule(groupReference, false)).isNull();
+    assertThat(permission.build().getRule(groupReference)).isNull();
+    assertThat(permission.build().getRule(groupReference)).isNull();
   }
 
   @Test
   public void getRule() {
     GroupReference groupReference = GroupReference.create(AccountGroup.uuid("uuid-1"), "group1");
-    PermissionRule permissionRule = PermissionRule.create(groupReference);
-    permission.setRules(ImmutableList.of(permissionRule));
-    assertThat(permission.getRule(groupReference)).isEqualTo(permissionRule);
-  }
-
-  @Test
-  public void createMissingRuleOnGet() {
-    GroupReference groupReference = GroupReference.create(AccountGroup.uuid("uuid-1"), "group1");
-    assertThat(permission.getRule(groupReference)).isNull();
-
-    assertThat(permission.getRule(groupReference, true))
-        .isEqualTo(PermissionRule.create(groupReference));
+    PermissionRule.Builder permissionRule = PermissionRule.builder(groupReference);
+    permission.add(permissionRule);
+    assertThat(permission.build().getRule(groupReference)).isEqualTo(permissionRule.build());
   }
 
   @Test
   public void addRule() {
-    PermissionRule permissionRule1 =
-        PermissionRule.create(GroupReference.create(AccountGroup.uuid("uuid-1"), "group1"));
-    PermissionRule permissionRule2 =
-        PermissionRule.create(GroupReference.create(AccountGroup.uuid("uuid-2"), "group2"));
-    permission.setRules(ImmutableList.of(permissionRule1, permissionRule2));
+    PermissionRule.Builder permissionRule1 =
+        PermissionRule.builder(GroupReference.create(AccountGroup.uuid("uuid-1"), "group1"));
+    PermissionRule.Builder permissionRule2 =
+        PermissionRule.builder(GroupReference.create(AccountGroup.uuid("uuid-2"), "group2"));
+    permission.add(permissionRule1);
+    permission.add(permissionRule2);
     GroupReference groupReference3 = GroupReference.create(AccountGroup.uuid("uuid-3"), "group3");
-    assertThat(permission.getRule(groupReference3)).isNull();
+    assertThat(permission.build().getRule(groupReference3)).isNull();
 
-    PermissionRule permissionRule3 = PermissionRule.create(groupReference3);
+    PermissionRule.Builder permissionRule3 = PermissionRule.builder(groupReference3);
     permission.add(permissionRule3);
-    assertThat(permission.getRule(groupReference3)).isEqualTo(permissionRule3);
-    assertThat(permission.getRules())
-        .containsExactly(permissionRule1, permissionRule2, permissionRule3)
+    assertThat(permission.build().getRule(groupReference3)).isEqualTo(permissionRule3.build());
+    assertThat(permission.build().getRules())
+        .containsExactly(permissionRule1.build(), permissionRule2.build(), permissionRule3.build())
         .inOrder();
   }
 
   @Test
   public void removeRule() {
-    PermissionRule permissionRule1 =
-        PermissionRule.create(GroupReference.create(AccountGroup.uuid("uuid-1"), "group1"));
-    PermissionRule permissionRule2 =
-        PermissionRule.create(GroupReference.create(AccountGroup.uuid("uuid-2"), "group2"));
+    PermissionRule.Builder permissionRule1 =
+        PermissionRule.builder(GroupReference.create(AccountGroup.uuid("uuid-1"), "group1"));
+    PermissionRule.Builder permissionRule2 =
+        PermissionRule.builder(GroupReference.create(AccountGroup.uuid("uuid-2"), "group2"));
     GroupReference groupReference3 = GroupReference.create(AccountGroup.uuid("uuid-3"), "group3");
-    PermissionRule permissionRule3 = PermissionRule.create(groupReference3);
+    PermissionRule.Builder permissionRule3 = PermissionRule.builder(groupReference3);
 
-    permission.setRules(ImmutableList.of(permissionRule1, permissionRule2, permissionRule3));
-    assertThat(permission.getRule(groupReference3)).isNotNull();
+    permission.add(permissionRule1);
+    permission.add(permissionRule2);
+    permission.add(permissionRule3);
+    assertThat(permission.build().getRule(groupReference3)).isNotNull();
 
-    permission.remove(permissionRule3);
-    assertThat(permission.getRule(groupReference3)).isNull();
-    assertThat(permission.getRules()).containsExactly(permissionRule1, permissionRule2).inOrder();
-  }
-
-  @Test
-  public void removeRuleByGroupReference() {
-    PermissionRule permissionRule1 =
-        PermissionRule.create(GroupReference.create(AccountGroup.uuid("uuid-1"), "group1"));
-    PermissionRule permissionRule2 =
-        PermissionRule.create(GroupReference.create(AccountGroup.uuid("uuid-2"), "group2"));
-    GroupReference groupReference3 = GroupReference.create(AccountGroup.uuid("uuid-3"), "group3");
-    PermissionRule permissionRule3 = PermissionRule.create(groupReference3);
-
-    permission.setRules(ImmutableList.of(permissionRule1, permissionRule2, permissionRule3));
-    assertThat(permission.getRule(groupReference3)).isNotNull();
-
-    permission.removeRule(groupReference3);
-    assertThat(permission.getRule(groupReference3)).isNull();
-    assertThat(permission.getRules()).containsExactly(permissionRule1, permissionRule2).inOrder();
-  }
-
-  @Test
-  public void clearRules() {
-    PermissionRule permissionRule1 =
-        PermissionRule.create(GroupReference.create(AccountGroup.uuid("uuid-1"), "group1"));
-    PermissionRule permissionRule2 =
-        PermissionRule.create(GroupReference.create(AccountGroup.uuid("uuid-2"), "group2"));
-
-    permission.setRules(ImmutableList.of(permissionRule1, permissionRule2));
-    assertThat(permission.getRules()).isNotEmpty();
-
-    permission.clearRules();
-    assertThat(permission.getRules()).isEmpty();
-  }
-
-  @Test
-  public void mergePermissions() {
-    PermissionRule permissionRule1 =
-        PermissionRule.create(GroupReference.create(AccountGroup.uuid("uuid-1"), "group1"));
-    PermissionRule permissionRule2 =
-        PermissionRule.create(GroupReference.create(AccountGroup.uuid("uuid-2"), "group2"));
-    PermissionRule permissionRule3 =
-        PermissionRule.create(GroupReference.create(AccountGroup.uuid("uuid-3"), "group3"));
-
-    Permission permission1 = new Permission("foo");
-    permission1.setRules(ImmutableList.of(permissionRule1, permissionRule2));
-
-    Permission permission2 = new Permission("bar");
-    permission2.setRules(ImmutableList.of(permissionRule2, permissionRule3));
-
-    permission1.mergeFrom(permission2);
-    assertThat(permission1.getRules())
-        .containsExactly(permissionRule1, permissionRule2, permissionRule3)
+    permission.remove(permissionRule3.build());
+    assertThat(permission.build().getRule(groupReference3)).isNull();
+    assertThat(permission.build().getRules())
+        .containsExactly(permissionRule1.build(), permissionRule2.build())
         .inOrder();
   }
 
   @Test
+  public void removeRuleByGroupReference() {
+    PermissionRule.Builder permissionRule1 =
+        PermissionRule.builder(GroupReference.create(AccountGroup.uuid("uuid-1"), "group1"));
+    PermissionRule.Builder permissionRule2 =
+        PermissionRule.builder(GroupReference.create(AccountGroup.uuid("uuid-2"), "group2"));
+    GroupReference groupReference3 = GroupReference.create(AccountGroup.uuid("uuid-3"), "group3");
+    PermissionRule.Builder permissionRule3 = PermissionRule.builder(groupReference3);
+
+    permission.add(permissionRule1);
+    permission.add(permissionRule2);
+    permission.add(permissionRule3);
+    assertThat(permission.build().getRule(groupReference3)).isNotNull();
+
+    permission.removeRule(groupReference3);
+    assertThat(permission.build().getRule(groupReference3)).isNull();
+    assertThat(permission.build().getRules())
+        .containsExactly(permissionRule1.build(), permissionRule2.build())
+        .inOrder();
+  }
+
+  @Test
+  public void clearRules() {
+    PermissionRule.Builder permissionRule1 =
+        PermissionRule.builder(GroupReference.create(AccountGroup.uuid("uuid-1"), "group1"));
+    PermissionRule.Builder permissionRule2 =
+        PermissionRule.builder(GroupReference.create(AccountGroup.uuid("uuid-2"), "group2"));
+
+    permission.add(permissionRule1);
+    permission.add(permissionRule2);
+    assertThat(permission.build().getRules()).isNotEmpty();
+
+    permission.clearRules();
+    assertThat(permission.build().getRules()).isEmpty();
+  }
+
+  @Test
   public void testEquals() {
-    PermissionRule permissionRule1 =
-        PermissionRule.create(GroupReference.create(AccountGroup.uuid("uuid-1"), "group1"));
-    PermissionRule permissionRule2 =
-        PermissionRule.create(GroupReference.create(AccountGroup.uuid("uuid-2"), "group2"));
+    PermissionRule.Builder permissionRule1 =
+        PermissionRule.builder(GroupReference.create(AccountGroup.uuid("uuid-1"), "group1"));
+    PermissionRule.Builder permissionRule2 =
+        PermissionRule.builder(GroupReference.create(AccountGroup.uuid("uuid-2"), "group2"));
 
-    permission.setRules(ImmutableList.of(permissionRule1, permissionRule2));
+    permission.add(permissionRule1);
+    permission.add(permissionRule2);
 
-    Permission permissionSameRulesOtherName = new Permission("bar");
-    permissionSameRulesOtherName.setRules(ImmutableList.of(permissionRule1, permissionRule2));
+    Permission.Builder permissionSameRulesOtherName = Permission.builder("bar");
+    permissionSameRulesOtherName.add(permissionRule1);
+    permissionSameRulesOtherName.add(permissionRule2);
     assertThat(permission.equals(permissionSameRulesOtherName)).isFalse();
 
-    Permission permissionSameRulesSameNameOtherExclusiveGroup = new Permission("foo");
-    permissionSameRulesSameNameOtherExclusiveGroup.setRules(
-        ImmutableList.of(permissionRule1, permissionRule2));
+    Permission.Builder permissionSameRulesSameNameOtherExclusiveGroup = Permission.builder("foo");
+    permissionSameRulesSameNameOtherExclusiveGroup.add(permissionRule1);
+    permissionSameRulesSameNameOtherExclusiveGroup.add(permissionRule2);
     permissionSameRulesSameNameOtherExclusiveGroup.setExclusiveGroup(true);
     assertThat(permission.equals(permissionSameRulesSameNameOtherExclusiveGroup)).isFalse();
 
-    Permission permissionOther = new Permission(PERMISSION_NAME);
-    permissionOther.setRules(ImmutableList.of(permissionRule1));
-    assertThat(permission.equals(permissionOther)).isFalse();
+    Permission.Builder permissionOther = Permission.builder(PERMISSION_NAME);
+    permissionOther.add(permissionRule1);
+    assertThat(permission.build().equals(permissionOther.build())).isFalse();
 
     permissionOther.add(permissionRule2);
-    assertThat(permission.equals(permissionOther)).isTrue();
+    assertThat(permission.build().equals(permissionOther.build())).isTrue();
   }
 }
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/BUILD b/javatests/com/google/gerrit/server/cache/serialize/entities/BUILD
new file mode 100644
index 0000000..c781d86
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/BUILD
@@ -0,0 +1,25 @@
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+junit_tests(
+    name = "tests",
+    srcs = glob(["*.java"]),
+    deps = [
+        "//java/com/google/gerrit/entities",
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/cache/serialize",
+        "//java/com/google/gerrit/server/cache/serialize/entities",
+        "//java/com/google/gerrit/server/cache/testing",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//lib:guava",
+        "//lib:jgit",
+        "//lib:junit",
+        "//lib:protobuf",
+        "//lib/auto:auto-value",
+        "//lib/auto:auto-value-annotations",
+        "//lib/truth",
+        "//lib/truth:truth-proto-extension",
+        "//proto:cache_java_proto",
+        "//proto/testing:test_java_proto",
+    ],
+)
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/ProjectSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/ProjectSerializerTest.java
new file mode 100644
index 0000000..8d13247
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/ProjectSerializerTest.java
@@ -0,0 +1,61 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.serialize.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.cache.serialize.entities.ProjectSerializer.deserialize;
+import static com.google.gerrit.server.cache.serialize.entities.ProjectSerializer.serialize;
+
+import com.google.gerrit.entities.BooleanProjectConfig;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.gerrit.extensions.client.ProjectState;
+import com.google.gerrit.extensions.client.SubmitType;
+import org.junit.Test;
+
+public class ProjectSerializerTest {
+  @Test
+  public void roundTrip() {
+    Project projectAutoValue =
+        Project.builder(Project.nameKey("test"))
+            .setDescription("desc")
+            .setSubmitType(SubmitType.FAST_FORWARD_ONLY)
+            .setState(ProjectState.HIDDEN)
+            .setParent(Project.nameKey("parent"))
+            .setMaxObjectSizeLimit("11K")
+            .setDefaultDashboard("dashboard1")
+            .setLocalDefaultDashboard("dashboard2")
+            .setConfigRefState("1337")
+            .setBooleanConfig(
+                BooleanProjectConfig.ENABLE_REVIEWER_BY_EMAIL, InheritableBoolean.TRUE)
+            .setBooleanConfig(
+                BooleanProjectConfig.CREATE_NEW_CHANGE_FOR_ALL_NOT_IN_TARGET,
+                InheritableBoolean.INHERIT)
+            .build();
+
+    assertThat(deserialize(serialize(projectAutoValue))).isEqualTo(projectAutoValue);
+  }
+
+  @Test
+  public void roundTripWithMinimalValues() {
+    Project projectAutoValue =
+        Project.builder(Project.nameKey("test"))
+            .setSubmitType(SubmitType.FAST_FORWARD_ONLY)
+            .setState(ProjectState.HIDDEN)
+            .build();
+
+    assertThat(deserialize(serialize(projectAutoValue))).isEqualTo(projectAutoValue);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java b/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java
index ba8485b..ea210ab 100644
--- a/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java
+++ b/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java
@@ -98,10 +98,15 @@
 
   private void configureProject() throws Exception {
     ProjectConfig pc = loadAllProjects();
-    for (AccessSection sec : pc.getAccessSections()) {
-      for (String label : pc.getLabelSections().keySet()) {
-        sec.removePermission(forLabel(label));
-      }
+
+    for (AccessSection sec : ImmutableList.copyOf(pc.getAccessSections())) {
+      pc.upsertAccessSection(
+          sec.getName(),
+          updatedSection -> {
+            for (String label : pc.getLabelSections().keySet()) {
+              updatedSection.removePermission(forLabel(label));
+            }
+          });
     }
     LabelType lt =
         label("Verified", value(1, "Verified"), value(0, "No score"), value(-1, "Fails"));
diff --git a/javatests/com/google/gerrit/server/project/CommitsCollectionTest.java b/javatests/com/google/gerrit/server/project/CommitsCollectionTest.java
index 5e94daa..5e57551 100644
--- a/javatests/com/google/gerrit/server/project/CommitsCollectionTest.java
+++ b/javatests/com/google/gerrit/server/project/CommitsCollectionTest.java
@@ -228,6 +228,7 @@
             .getAllProjects()
             .getConfig()
             .getAccessSection(AccessSection.GLOBAL_CAPABILITIES)
+            .orElseThrow(() -> new IllegalStateException("access section does not exist"))
             .getPermission(GlobalCapability.ADMINISTRATE_SERVER);
 
     return adminPermission.getRules().stream()
diff --git a/javatests/com/google/gerrit/server/project/ProjectConfigTest.java b/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
index b7125c3..2d31acf 100644
--- a/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
+++ b/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
@@ -303,12 +303,15 @@
     update(rev);
 
     ProjectConfig cfg = read(rev);
-    AccessSection section = cfg.getAccessSection("refs/heads/*");
-    cfg.getAccountsSection()
-        .setSameGroupVisibility(
-            Collections.singletonList(PermissionRule.create(cfg.resolve(staff))));
-    Permission submit = section.getPermission(Permission.SUBMIT);
-    submit.add(PermissionRule.create(cfg.resolve(staff)));
+    cfg.upsertAccessSection(
+        "refs/heads/*",
+        section -> {
+          Permission.Builder submit = section.upsertPermission(Permission.SUBMIT);
+          submit.add(PermissionRule.builder(cfg.resolve(staff)));
+        });
+    cfg.setAccountsSection(
+        AccountsSection.create(
+            Collections.singletonList(PermissionRule.create(cfg.resolve(staff)))));
     ContributorAgreement.Builder ca = cfg.getContributorAgreement("Individual").toBuilder();
     ca.setAccepted(ImmutableList.of(PermissionRule.create(cfg.resolve(staff))));
     ca.setAutoVerify(null);
@@ -423,9 +426,12 @@
     update(rev);
 
     ProjectConfig cfg = read(rev);
-    AccessSection section = cfg.getAccessSection("refs/heads/*");
-    Permission submit = section.getPermission(Permission.SUBMIT);
-    submit.add(PermissionRule.create(cfg.resolve(staff)));
+    cfg.upsertAccessSection(
+        "refs/heads/*",
+        section -> {
+          Permission.Builder submit = section.upsertPermission(Permission.SUBMIT);
+          submit.add(PermissionRule.builder(cfg.resolve(staff)));
+        });
     rev = commit(cfg);
     assertThat(text(rev, "project.config"))
         .isEqualTo(
@@ -692,7 +698,7 @@
     update(rev);
 
     ProjectConfig cfg = read(rev);
-    cfg.getAccountsSection().setSameGroupVisibility(ImmutableList.of());
+    cfg.setAccountsSection(AccountsSection.create(ImmutableList.of()));
     rev = commit(cfg);
     assertThat(text(rev, "project.config"))
         .isEqualTo(
diff --git a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index 968d4f7..2eb25da 100644
--- a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -44,7 +44,6 @@
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.Permission;
@@ -1859,13 +1858,16 @@
     try (MetaDataUpdate md = metaDataUpdateFactory.create(project)) {
       md.setMessage(String.format("Grant %s on %s", permission, ref));
       ProjectConfig config = projectConfigFactory.read(md);
-      AccessSection s = config.getAccessSection(ref, true);
-      Permission p = s.getPermission(permission, true);
-      PermissionRule rule =
-          PermissionRule.builder(GroupReference.create(groupUUID, groupUUID.get()))
-              .setForce(force)
-              .build();
-      p.add(rule);
+      config.upsertAccessSection(
+          ref,
+          s -> {
+            Permission.Builder p = s.upsertPermission(permission);
+            PermissionRule.Builder rule =
+                PermissionRule.builder(GroupReference.create(groupUUID, groupUUID.get()))
+                    .setForce(force);
+            p.add(rule);
+          });
+
       config.commit(md);
       projectCache.evict(config.getProject());
     }
diff --git a/plugins/delete-project b/plugins/delete-project
index f420d06..7cb59ec 160000
--- a/plugins/delete-project
+++ b/plugins/delete-project
@@ -1 +1 @@
-Subproject commit f420d06562b97eab26a627baa7722c7f84d95763
+Subproject commit 7cb59ecacbbe7bc995873ae112e48cf0ff521d2a
diff --git a/polygerrit-ui/app/.eslintrc.js b/polygerrit-ui/app/.eslintrc.js
index cc9f304..932997e 100644
--- a/polygerrit-ui/app/.eslintrc.js
+++ b/polygerrit-ui/app/.eslintrc.js
@@ -149,7 +149,6 @@
         }
       }
     }],
-    "import/named": 2,
     "import/no-self-import": 2,
     // The no-cycle rule is slow, because it doesn't cache dependencies.
     // Disable it.
@@ -183,6 +182,7 @@
         // The rule is required for .js files only, because typescript compiler
         // always checks import.
         "import/no-unresolved": 2,
+        "import/named": 2,
       },
       "globals": {
         "goog": "readonly",
@@ -193,10 +193,10 @@
       "extends": [require.resolve("gts/.eslintrc.json")],
       "rules": {
         // The following rules is required to match internal google rules
-        "@typescript-eslint/restrict-plus-operands": "error"
+        "@typescript-eslint/restrict-plus-operands": "error",
       },
       "parserOptions": {
-        "project": path.resolve(__dirname, "./tsconfig.json"),
+        "project": path.resolve(__dirname, "./tsconfig_eslint.json"),
       }
     },
     {
@@ -209,6 +209,13 @@
       }
     },
     {
+      "files": ["**/*.d.ts"],
+      "rules": {
+        // See details in the //tools/js/eslint-rules/report-ts-error.js file.
+        "report-ts-error": "error",
+      }
+    },
+    {
       "files": ["*.html", "test.js", "test-infra.js", "template_test.js"],
       "rules": {
         "jsdoc/require-file-overview": "off"
diff --git a/polygerrit-ui/app/BUILD b/polygerrit-ui/app/BUILD
index fb2bd73..054033c 100644
--- a/polygerrit-ui/app/BUILD
+++ b/polygerrit-ui/app/BUILD
@@ -114,6 +114,8 @@
         ".eslintrc.js",
         ".prettierrc.js",
         ".eslint-ts-resolver.js",
+        "tsconfig_eslint.json",
+        # tsconfig_eslint.json extends tsconfig.json, pass it as a dependency
         "tsconfig.json",
     ],
     extensions = [
diff --git a/polygerrit-ui/app/behaviors/async-foreach-behavior/async-foreach-behavior.js b/polygerrit-ui/app/behaviors/async-foreach-behavior/async-foreach-behavior.js
deleted file mode 100644
index 1d384bc..0000000
--- a/polygerrit-ui/app/behaviors/async-foreach-behavior/async-foreach-behavior.js
+++ /dev/null
@@ -1,48 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-/** @polymerBehavior AsyncForeachBehavior */
-export const AsyncForeachBehavior = {
-  /**
-   * @template T
-   * @param {!Array<T>} array
-   * @param {!Function} fn An iteratee function to be passed each element of
-   *     the array in order. Must return a promise, and the following
-   *     iteration will not begin until resolution of the promise returned by
-   *     the previous iteration.
-   *
-   *     An optional second argument to fn is a callback that will halt the
-   *     loop if called.
-   * @return {!Promise<undefined>}
-   */
-  asyncForeach(array, fn) {
-    if (!array.length) { return Promise.resolve(); }
-    let stop = false;
-    const stopCallback = () => { stop = true; };
-    return fn(array[0], stopCallback).then(exit => {
-      if (stop) { return Promise.resolve(); }
-      return this.asyncForeach(array.slice(1), fn);
-    });
-  },
-};
-
-// TODO(dmfilippov) Remove the following lines with assignments
-// Plugins can use the behavior because it was accessible with
-// the global Gerrit... variable. To avoid breaking changes in plugins
-// temporary assign global variables.
-window.Gerrit = window.Gerrit || {};
-window.Gerrit.AsyncForeachBehavior = AsyncForeachBehavior;
diff --git a/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior.ts b/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior.ts
deleted file mode 100644
index 6b726a6..0000000
--- a/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-// This is a temporary interface. Must be removed when base-url-behavior
-// is converted to a mixin or an util class. See:
-// https://polymer-library.polymer-project.org/3.0/docs/devguide/custom-elements#mixins
-export interface BaseUrlBehaviorInterface {
-  getBaseUrl(): string;
-}
-
-/** @polymerBehavior BaseUrlBehavior */
-export const BaseUrlBehavior = {
-  /** @return {string} */
-  getBaseUrl() {
-    return window.CANONICAL_PATH || '';
-  },
-};
-
-// TODO(dmfilippov) Remove the following lines with assignments
-// Plugins can use the behavior because it was accessible with
-// the global Gerrit... variable. To avoid breaking changes in plugins
-// temporary assign global variables.
-window.Gerrit = window.Gerrit || {};
-window.Gerrit.BaseUrlBehavior = BaseUrlBehavior;
diff --git a/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior_test.js b/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior_test.js
deleted file mode 100644
index 3b8a9cb..0000000
--- a/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior_test.js
+++ /dev/null
@@ -1,51 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../test/common-test-setup-karma.js';
-import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
-import {BaseUrlBehavior} from './base-url-behavior.js';
-
-const basicFixture = fixtureFromElement('base-url-behavior-test-element');
-
-suite('base-url-behavior tests', () => {
-  let element;
-  let originialCanonicalPath;
-
-  suiteSetup(() => {
-    originialCanonicalPath = window.CANONICAL_PATH;
-    window.CANONICAL_PATH = '/r';
-    // Define a Polymer element that uses this behavior.
-    Polymer({
-      is: 'base-url-behavior-test-element',
-      behaviors: [
-        BaseUrlBehavior,
-      ],
-    });
-  });
-
-  suiteTeardown(() => {
-    window.CANONICAL_PATH = originialCanonicalPath;
-  });
-
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  test('getBaseUrl', () => {
-    assert.deepEqual(element.getBaseUrl(), '/r');
-  });
-});
diff --git a/polygerrit-ui/app/behaviors/docs-url-behavior/docs-url-behavior.ts b/polygerrit-ui/app/behaviors/docs-url-behavior/docs-url-behavior.ts
deleted file mode 100644
index 4bc2f12..0000000
--- a/polygerrit-ui/app/behaviors/docs-url-behavior/docs-url-behavior.ts
+++ /dev/null
@@ -1,88 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {
-  BaseUrlBehavior,
-  BaseUrlBehaviorInterface,
-} from '../base-url-behavior/base-url-behavior';
-
-const PROBE_PATH = '/Documentation/index.html';
-const DOCS_BASE_PATH = '/Documentation';
-
-let cachedPromise: Promise<string | null> | undefined;
-
-// NOTE: Below we define 2 types (DocUrlBehaviorConfig and RestApi) to avoid
-// type 'any'. These are temporary definitions and they must be
-// updated/moved/removed when we start converting our codebase to typescript.
-// Right now we are using these types here just for adding typescript support to
-// our build/test infrastructure. Doing so we avoid massive code updates at this
-// stage.
-
-// TODO: introduce global gerrit config type instead of DocUrlBehaviorConfig.
-// The DocUrlBehaviorConfig is a temporary type
-interface DocUrlBehaviorConfig {
-  gerrit?: {doc_url?: string};
-}
-
-// TODO: implement RestApi type correctly and remove interface from this file
-interface RestApi {
-  probePath(url: string): Promise<boolean>;
-}
-
-/** @polymerBehavior DocsUrlBehavior */
-export const DocsUrlBehavior = [
-  {
-    /**
-     * Get the docs base URL from either the server config or by probing.
-     *
-     * @param {Object} config The server config.
-     * @param {!Object} restApi A REST API instance
-     * @return {!Promise<string>} A promise that resolves with the docs base
-     *     URL.
-     */
-    getDocsBaseUrl(
-      this: BaseUrlBehaviorInterface,
-      config: DocUrlBehaviorConfig,
-      restApi: RestApi
-    ) {
-      if (!cachedPromise) {
-        cachedPromise = new Promise(resolve => {
-          if (config && config.gerrit && config.gerrit.doc_url) {
-            resolve(config.gerrit.doc_url);
-          } else {
-            restApi.probePath(this.getBaseUrl() + PROBE_PATH).then(ok => {
-              resolve(ok ? this.getBaseUrl() + DOCS_BASE_PATH : null);
-            });
-          }
-        });
-      }
-      return cachedPromise;
-    },
-
-    /** For testing only. */
-    _clearDocsBaseUrlCache() {
-      cachedPromise = undefined;
-    },
-  },
-  BaseUrlBehavior,
-];
-
-// TODO(dmfilippov) Remove the following lines with assignments
-// Plugins can use the behavior because it was accessible with
-// the global Gerrit... variable. To avoid breaking changes in plugins
-// temporary assign global variables.
-window.Gerrit = window.Gerrit || {};
-window.Gerrit.DocsUrlBehavior = DocsUrlBehavior;
diff --git a/polygerrit-ui/app/behaviors/docs-url-behavior/docs-url-behavior_test.js b/polygerrit-ui/app/behaviors/docs-url-behavior/docs-url-behavior_test.js
deleted file mode 100644
index 93beb52..0000000
--- a/polygerrit-ui/app/behaviors/docs-url-behavior/docs-url-behavior_test.js
+++ /dev/null
@@ -1,89 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../test/common-test-setup-karma.js';
-import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
-import {DocsUrlBehavior} from './docs-url-behavior.js';
-
-const basicFixture = fixtureFromElement('docs-url-behavior-element');
-
-suite('docs-url-behavior tests', () => {
-  let element;
-
-  suiteSetup(() => {
-    // Define a Polymer element that uses this behavior.
-    Polymer({
-      is: 'docs-url-behavior-element',
-      behaviors: [DocsUrlBehavior],
-    });
-  });
-
-  setup(() => {
-    element = basicFixture.instantiate();
-    element._clearDocsBaseUrlCache();
-  });
-
-  test('null config', () => {
-    const mockRestApi = {
-      probePath: sinon.stub().returns(Promise.resolve(true)),
-    };
-    return element.getDocsBaseUrl(null, mockRestApi)
-        .then(docsBaseUrl => {
-          assert.isTrue(
-              mockRestApi.probePath.calledWith('/Documentation/index.html'));
-          assert.equal(docsBaseUrl, '/Documentation');
-        });
-  });
-
-  test('no doc config', () => {
-    const mockRestApi = {
-      probePath: sinon.stub().returns(Promise.resolve(true)),
-    };
-    const config = {gerrit: {}};
-    return element.getDocsBaseUrl(config, mockRestApi)
-        .then(docsBaseUrl => {
-          assert.isTrue(
-              mockRestApi.probePath.calledWith('/Documentation/index.html'));
-          assert.equal(docsBaseUrl, '/Documentation');
-        });
-  });
-
-  test('has doc config', () => {
-    const mockRestApi = {
-      probePath: sinon.stub().returns(Promise.resolve(true)),
-    };
-    const config = {gerrit: {doc_url: 'foobar'}};
-    return element.getDocsBaseUrl(config, mockRestApi)
-        .then(docsBaseUrl => {
-          assert.isFalse(mockRestApi.probePath.called);
-          assert.equal(docsBaseUrl, 'foobar');
-        });
-  });
-
-  test('no probe', () => {
-    const mockRestApi = {
-      probePath: sinon.stub().returns(Promise.resolve(false)),
-    };
-    return element.getDocsBaseUrl(null, mockRestApi)
-        .then(docsBaseUrl => {
-          assert.isTrue(
-              mockRestApi.probePath.calledWith('/Documentation/index.html'));
-          assert.isNotOk(docsBaseUrl);
-        });
-  });
-});
-
diff --git a/polygerrit-ui/app/behaviors/gr-list-view-behavior/gr-list-view-behavior.js b/polygerrit-ui/app/behaviors/gr-list-view-behavior/gr-list-view-behavior.js
index 813c64a..180fcc8 100644
--- a/polygerrit-ui/app/behaviors/gr-list-view-behavior/gr-list-view-behavior.js
+++ b/polygerrit-ui/app/behaviors/gr-list-view-behavior/gr-list-view-behavior.js
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 
-import {BaseUrlBehavior} from '../base-url-behavior/base-url-behavior.js';
+import {getBaseUrl} from '../../utils/url-util.js';
 import {URLEncodingBehavior} from '../gr-url-encoding-behavior/gr-url-encoding-behavior.js';
 
 /** @polymerBehavior ListViewBehavior */
@@ -29,7 +29,7 @@
   },
 
   getUrl(path, item) {
-    return this.getBaseUrl() + path + this.encodeURL(item, true);
+    return getBaseUrl() + path + this.encodeURL(item, true);
   },
 
   /**
@@ -52,7 +52,6 @@
     return 0;
   },
 },
-BaseUrlBehavior,
 URLEncodingBehavior,
 ];
 
diff --git a/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior.ts b/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior.ts
index 3b30665..5671387 100644
--- a/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior.ts
+++ b/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior.ts
@@ -14,10 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {
-  BaseUrlBehavior,
-  BaseUrlBehaviorInterface,
-} from '../base-url-behavior/base-url-behavior';
+import {getBaseUrl} from '../../utils/url-util';
 import {ChangeStatus} from '../../constants/constants';
 
 // WARNING: The types below can be completely wrong!
@@ -131,14 +128,9 @@
     /**
      *  @return {string}
      */
-    changeBaseURL(
-      this: BaseUrlBehaviorInterface,
-      project: string,
-      changeNum: ChangeNum,
-      patchNum: PatchNum
-    ) {
+    changeBaseURL(project: string, changeNum: ChangeNum, patchNum: PatchNum) {
       let v =
-        this.getBaseUrl() +
+        getBaseUrl() +
         '/changes/' +
         encodeURIComponent(project) +
         '~' +
@@ -149,8 +141,8 @@
       return v;
     },
 
-    changePath(this: BaseUrlBehaviorInterface, changeNum: ChangeNum) {
-      return this.getBaseUrl() + '/c/' + changeNum;
+    changePath(changeNum: ChangeNum) {
+      return getBaseUrl() + '/c/' + changeNum;
     },
 
     changeIsOpen(change?: Change) {
@@ -207,7 +199,6 @@
       return this.changeStatuses(change).join(', ');
     },
   },
-  BaseUrlBehavior,
 ];
 
 // eslint-disable-next-line @typescript-eslint/ban-ts-ignore
diff --git a/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior_test.js b/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior_test.js
index 8f63a25..16f6111 100644
--- a/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior_test.js
+++ b/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior_test.js
@@ -17,7 +17,6 @@
 
 import '../../test/common-test-setup-karma.js';
 import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
-import {BaseUrlBehavior} from '../base-url-behavior/base-url-behavior.js';
 import {RESTClientBehavior} from './rest-client-behavior.js';
 import {html} from '@polymer/polymer/lib/utils/html-tag.js';
 
@@ -42,7 +41,6 @@
     Polymer({
       is: 'rest-client-behavior-test-element',
       behaviors: [
-        BaseUrlBehavior,
         RESTClientBehavior,
       ],
     });
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.js b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.js
index 25ba83e..5b8896e 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.js
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.js
@@ -38,7 +38,7 @@
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-admin-view_html.js';
-import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
+import {getBaseUrl} from '../../../utils/url-util.js';
 import {AdminNavBehavior} from '../../../behaviors/gr-admin-nav-behavior/gr-admin-nav-behavior.js';
 import {URLEncodingBehavior} from '../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
@@ -51,7 +51,6 @@
  */
 class GrAdminView extends mixinBehaviors( [
   AdminNavBehavior,
-  BaseUrlBehavior,
   URLEncodingBehavior,
 ], GestureEventListeners(
     LegacyElementMixin(
@@ -238,7 +237,7 @@
   // updated. They are currently copied from gr-dropdown (and should be
   // updated there as well once complete).
   _computeURLHelper(host, path) {
-    return '//' + host + this.getBaseUrl() + path;
+    return '//' + host + getBaseUrl() + path;
   }
 
   _computeRelativeURL(path) {
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.js b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.js
index 25b14e2..f8b5abd 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.js
@@ -20,6 +20,7 @@
 import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
+import {stubBaseUrl} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-admin-view');
 
@@ -50,7 +51,7 @@
         element._computeLinkURL({url: '/test', noBaseUrl: true}),
         '//' + window.location.host + '/test');
 
-    sinon.stub(element, 'getBaseUrl').returns('/foo');
+    stubBaseUrl('/foo');
     assert.equal(
         element._computeLinkURL({url: '/test', noBaseUrl: true}),
         '//' + window.location.host + '/foo/test');
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.js b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.js
index e16f5ce..91573fc 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.js
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.js
@@ -26,7 +26,6 @@
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-create-change-dialog_html.js';
-import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
 import {URLEncodingBehavior} from '../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 
@@ -37,7 +36,6 @@
  * @extends PolymerElement
  */
 class GrCreateChangeDialog extends mixinBehaviors( [
-  BaseUrlBehavior,
   URLEncodingBehavior,
 ], GestureEventListeners(
     LegacyElementMixin(
diff --git a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.js b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.js
index 7f33663..ea77dd7 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.js
+++ b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.js
@@ -23,7 +23,7 @@
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-create-group-dialog_html.js';
-import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
+import {getBaseUrl} from '../../../utils/url-util.js';
 import {URLEncodingBehavior} from '../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.js';
 import page from 'page/page.mjs';
 
@@ -31,7 +31,6 @@
  * @extends PolymerElement
  */
 class GrCreateGroupDialog extends mixinBehaviors( [
-  BaseUrlBehavior,
   URLEncodingBehavior,
 ], GestureEventListeners(
     LegacyElementMixin(
@@ -63,7 +62,7 @@
   }
 
   _computeGroupUrl(groupId) {
-    return this.getBaseUrl() + '/admin/groups/' +
+    return getBaseUrl() + '/admin/groups/' +
         this.encodeURL(groupId, true);
   }
 
diff --git a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.js b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.js
index eb131f5..635e3b1 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.js
+++ b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.js
@@ -25,7 +25,7 @@
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-create-pointer-dialog_html.js';
-import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
+import {getBaseUrl} from '../../../utils/url-util.js';
 import {URLEncodingBehavior} from '../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.js';
 import page from 'page/page.mjs';
 
@@ -38,7 +38,6 @@
  * @extends PolymerElement
  */
 class GrCreatePointerDialog extends mixinBehaviors( [
-  BaseUrlBehavior,
   URLEncodingBehavior,
 ], GestureEventListeners(
     LegacyElementMixin(
@@ -75,10 +74,10 @@
 
   _computeItemUrl(project) {
     if (this.itemDetail === DETAIL_TYPES.branches) {
-      return this.getBaseUrl() + '/admin/repos/' +
+      return getBaseUrl() + '/admin/repos/' +
           this.encodeURL(this.repoName, true) + ',branches';
     } else if (this.itemDetail === DETAIL_TYPES.tags) {
-      return this.getBaseUrl() + '/admin/repos/' +
+      return getBaseUrl() + '/admin/repos/' +
           this.encodeURL(this.repoName, true) + ',tags';
     }
   }
diff --git a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.js b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.js
index 0410f14..4872a39 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.js
+++ b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.js
@@ -26,7 +26,7 @@
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-create-repo-dialog_html.js';
-import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
+import {getBaseUrl} from '../../../utils/url-util.js';
 import {URLEncodingBehavior} from '../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.js';
 import page from 'page/page.mjs';
 
@@ -34,7 +34,6 @@
  * @extends PolymerElement
  */
 class GrCreateRepoDialog extends mixinBehaviors( [
-  BaseUrlBehavior,
   URLEncodingBehavior,
 ], GestureEventListeners(
     LegacyElementMixin(
@@ -95,7 +94,7 @@
   }
 
   _computeRepoUrl(repoName) {
-    return this.getBaseUrl() + '/admin/repos/' +
+    return getBaseUrl() + '/admin/repos/' +
         this.encodeURL(repoName, true);
   }
 
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.js b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.js
index c54a709..98985c1 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.js
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.js
@@ -30,7 +30,7 @@
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-group-members_html.js';
-import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
+import {getBaseUrl} from '../../../utils/url-util.js';
 import {URLEncodingBehavior} from '../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.js';
 
 const SUGGESTIONS_LIMIT = 15;
@@ -43,7 +43,6 @@
  * @extends PolymerElement
  */
 class GrGroupMembers extends mixinBehaviors( [
-  BaseUrlBehavior,
   URLEncodingBehavior,
 ], GestureEventListeners(
     LegacyElementMixin(
@@ -163,9 +162,9 @@
 
     // For GWT compatibility
     if (url.startsWith('#')) {
-      return this.getBaseUrl() + url.slice(1);
+      return getBaseUrl() + url.slice(1);
     }
-    return this.getBaseUrl() + url;
+    return getBaseUrl() + url;
   }
 
   _handleSavingGroupMember() {
@@ -230,16 +229,19 @@
 
   _handleSavingIncludedGroups() {
     return this.$.restAPI.saveIncludedGroup(this._groupName,
-        this._includedGroupSearchId.replace(/\+/g, ' '), err => {
-          if (err.status === 404) {
-            this.dispatchEvent(new CustomEvent('show-alert', {
-              detail: {message: SAVING_ERROR_TEXT},
-              bubbles: true,
-              composed: true,
-            }));
-            return err;
+        this._includedGroupSearchId.replace(/\+/g, ' '), (errResponse, err) => {
+          if (errResponse) {
+            if (errResponse.status === 404) {
+              this.dispatchEvent(new CustomEvent('show-alert', {
+                detail: {message: SAVING_ERROR_TEXT},
+                bubbles: true,
+                composed: true,
+              }));
+              return errResponse;
+            }
+            throw Error(err.statusText);
           }
-          throw Error(err.statusText);
+          throw err;
         })
         .then(config => {
           if (!config) {
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.js b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.js
index f047cfe..3e3e572 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.js
@@ -18,6 +18,7 @@
 import '../../../test/common-test-setup-karma.js';
 import './gr-group-members.js';
 import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {stubBaseUrl} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-group-members');
 
@@ -135,7 +136,7 @@
       },
     });
     element = basicFixture.instantiate();
-    sinon.stub(element, 'getBaseUrl').returns('https://test/site');
+    stubBaseUrl('https://test/site');
     element.groupId = 1;
     groupStub = sinon.stub(
         element.$.restAPI,
@@ -213,10 +214,12 @@
     const memberName = 'bad-name';
     const alertStub = sinon.stub();
     element.addEventListener('show-alert', alertStub);
-    const error = new Error('error');
-    error.status = 404;
-    sinon.stub(element.$.restAPI, 'saveGroupMembers').callsFake(
-        () => Promise.reject(error));
+    const errorResponse = {
+      status: 404,
+      ok: false,
+    };
+    sinon.stub(element.$.restAPI._restApiHelper, 'fetch').callsFake(
+        () => Promise.resolve(errorResponse));
 
     element.$.groupMemberSearchInput.text = memberName;
     element.$.groupMemberSearchInput.value = 1234;
@@ -226,6 +229,28 @@
     });
   });
 
+  test('add included group network-error throws an exception', async () => {
+    element._groupOwner = true;
+
+    const memberName = 'bad-name';
+    const alertStub = sinon.stub();
+    element.addEventListener('show-alert', alertStub);
+    const err = new Error();
+    sinon.stub(element.$.restAPI._restApiHelper, 'fetch').callsFake(
+        () => Promise.reject(err));
+
+    element.$.groupMemberSearchInput.text = memberName;
+    element.$.groupMemberSearchInput.value = 1234;
+
+    let exceptionThrown = false;
+    try {
+      await element._handleSavingIncludedGroups();
+    } catch (e) {
+      exceptionThrown = true;
+    }
+    assert.isTrue(exceptionThrown);
+  });
+
   test('_getAccountSuggestions empty', done => {
     element
         ._getAccountSuggestions('nonexistent').then(accounts => {
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.js b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.js
index f6a1c10..8308504 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.js
@@ -25,7 +25,7 @@
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-repo-access_html.js';
-import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
+import {getBaseUrl} from '../../../utils/url-util.js';
 import {AccessBehavior} from '../../../behaviors/gr-access-behavior/gr-access-behavior.js';
 import {URLEncodingBehavior} from '../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
@@ -85,7 +85,6 @@
  */
 class GrRepoAccess extends mixinBehaviors( [
   AccessBehavior,
-  BaseUrlBehavior,
   URLEncodingBehavior,
 ], GestureEventListeners(
     LegacyElementMixin(
@@ -514,7 +513,7 @@
   }
 
   _computeParentHref(repoName) {
-    return this.getBaseUrl() +
+    return getBaseUrl() +
         `/admin/repos/${this.encodeURL(repoName, true)},access`;
   }
 }
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.js b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.js
index 99957ff..f50b9ed 100644
--- a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.js
+++ b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.js
@@ -25,7 +25,7 @@
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-rule-editor_html.js';
-import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
+import {getBaseUrl} from '../../../utils/url-util.js';
 import {AccessBehavior} from '../../../behaviors/gr-access-behavior/gr-access-behavior.js';
 import {URLEncodingBehavior} from '../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.js';
 
@@ -81,7 +81,6 @@
  */
 class GrRuleEditor extends mixinBehaviors( [
   AccessBehavior,
-  BaseUrlBehavior,
   URLEncodingBehavior,
 ], GestureEventListeners(
     LegacyElementMixin(
@@ -172,7 +171,7 @@
   }
 
   _computeGroupPath(group) {
-    return `${this.getBaseUrl()}/admin/groups/${this.encodeURL(group, true)}`;
+    return `${getBaseUrl()}/admin/groups/${this.encodeURL(group, true)}`;
   }
 
   _handleAccessSaved() {
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.js b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.js
index 6bb7f0c..1e06795 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.js
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.js
@@ -31,7 +31,6 @@
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-change-list-item_html.js';
-import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
 import {ChangeTableBehavior} from '../../../behaviors/gr-change-table-behavior/gr-change-table-behavior.js';
 import {PathListBehavior} from '../../../behaviors/gr-path-list-behavior/gr-path-list-behavior.js';
 import {URLEncodingBehavior} from '../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.js';
@@ -57,7 +56,6 @@
  * @extends PolymerElement
  */
 class GrChangeListItem extends mixinBehaviors( [
-  BaseUrlBehavior,
   ChangeTableBehavior,
   PathListBehavior,
   RESTClientBehavior,
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.js b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.js
index 361ad83..baaf428 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.js
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.js
@@ -26,7 +26,6 @@
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-change-list-view_html.js';
-import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
 import {URLEncodingBehavior} from '../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.js';
 import page from 'page/page.mjs';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
@@ -47,7 +46,6 @@
  * @extends PolymerElement
  */
 class GrChangeListView extends mixinBehaviors( [
-  BaseUrlBehavior,
   URLEncodingBehavior,
 ], GestureEventListeners(
     LegacyElementMixin(
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js
index b238689..f873802 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js
@@ -29,7 +29,6 @@
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-change-list_html.js';
 import {appContext} from '../../../services/app-context.js';
-import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
 import {ChangeTableBehavior} from '../../../behaviors/gr-change-table-behavior/gr-change-table-behavior.js';
 import {URLEncodingBehavior} from '../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.js';
 import {KeyboardShortcutBehavior} from '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
@@ -47,7 +46,6 @@
  * @extends PolymerElement
  */
 class GrChangeList extends mixinBehaviors( [
-  BaseUrlBehavior,
   ChangeTableBehavior,
   KeyboardShortcutBehavior,
   RESTClientBehavior,
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
index 44700e9..df6490d 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
@@ -33,7 +33,7 @@
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-file-list_html.js';
-import {AsyncForeachBehavior} from '../../../behaviors/async-foreach-behavior/async-foreach-behavior.js';
+import {asyncForeach} from '../../../utils/async-util.js';
 import {DomUtilBehavior} from '../../../behaviors/dom-util-behavior/dom-util-behavior.js';
 import {PatchSetBehavior} from '../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js';
 import {PathListBehavior} from '../../../behaviors/gr-path-list-behavior/gr-path-list-behavior.js';
@@ -91,7 +91,6 @@
  * @extends PolymerElement
  */
 class GrFileList extends mixinBehaviors( [
-  AsyncForeachBehavior,
   DomUtilBehavior,
   KeyboardShortcutBehavior,
   PatchSetBehavior,
@@ -1292,7 +1291,7 @@
         detail: {resolve},
         composed: true, bubbles: true,
       }));
-    })).then(() => this.asyncForeach(files, (file, cancel) => {
+    })).then(() => asyncForeach(files, (file, cancel) => {
       const path = file.path;
       this._cancelForEachDiff = cancel;
 
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js
index 0690a87..d5416b2 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js
@@ -33,7 +33,6 @@
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-reply-dialog_html.js';
-import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
 import {PatchSetBehavior} from '../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js';
 import {KeyboardShortcutBehavior} from '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
 import {RESTClientBehavior} from '../../../behaviors/rest-client-behavior/rest-client-behavior.js';
@@ -82,7 +81,6 @@
  * @extends PolymerElement
  */
 class GrReplyDialog extends mixinBehaviors( [
-  BaseUrlBehavior,
   KeyboardShortcutBehavior,
   PatchSetBehavior,
   RESTClientBehavior,
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js
index a6aef95..7e4e568 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js
@@ -21,12 +21,11 @@
 import '../../shared/gr-overlay/gr-overlay.js';
 import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
 import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-error-manager_html.js';
-import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
+import {getBaseUrl} from '../../../utils/url-util.js';
 import {authService} from '../../shared/gr-rest-api-interface/gr-auth.js';
 import {appContext} from '../../../services/app-context.js';
 
@@ -41,11 +40,9 @@
 /**
  * @extends PolymerElement
  */
-class GrErrorManager extends mixinBehaviors( [
-  BaseUrlBehavior,
-], GestureEventListeners(
+class GrErrorManager extends GestureEventListeners(
     LegacyElementMixin(
-        PolymerElement))) {
+        PolymerElement)) {
   static get template() { return htmlTemplate; }
 
   static get is() { return 'gr-error-manager'; }
@@ -372,7 +369,7 @@
       'left=' + left,
       'top=' + top,
     ];
-    window.open(this.getBaseUrl() +
+    window.open(getBaseUrl() +
         '/login/%3FcloseAfterLogin', '_blank', options.join(','));
     this.listen(window, 'focus', '_handleWindowFocus');
   }
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js
index 8bc1870..17a7b0b 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js
@@ -26,8 +26,7 @@
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-main-header_html.js';
-import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
-import {DocsUrlBehavior} from '../../../behaviors/docs-url-behavior/docs-url-behavior.js';
+import {getBaseUrl, getDocsBaseUrl} from '../../../utils/url-util.js';
 import {AdminNavBehavior} from '../../../behaviors/gr-admin-nav-behavior/gr-admin-nav-behavior.js';
 import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
 
@@ -88,8 +87,6 @@
  */
 class GrMainHeader extends mixinBehaviors( [
   AdminNavBehavior,
-  BaseUrlBehavior,
-  DocsUrlBehavior,
 ], GestureEventListeners(
     LegacyElementMixin(
         PolymerElement))) {
@@ -185,7 +182,7 @@
   }
 
   _computeRelativeURL(path) {
-    return '//' + window.location.host + this.getBaseUrl() + path;
+    return '//' + window.location.host + getBaseUrl() + path;
   }
 
   _computeLinks(defaultLinks, userLinks, adminLinks, topMenus, docBaseUrl) {
@@ -288,7 +285,7 @@
     this.$.restAPI.getConfig()
         .then(config => {
           this._retrieveRegisterURL(config);
-          return this.getDocsBaseUrl(config, this.$.restAPI);
+          return getDocsBaseUrl(config, this.$.restAPI);
         })
         .then(docBaseUrl => { this._docBaseUrl = docBaseUrl; });
   }
@@ -334,7 +331,7 @@
   }
 
   _generateSettingsLink() {
-    return this.getBaseUrl() + '/settings/';
+    return getBaseUrl() + '/settings/';
   }
 
   _onMobileSearchTap(e) {
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router.js b/polygerrit-ui/app/elements/core/gr-router/gr-router.js
index b60f47e..83c91e0 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.js
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.js
@@ -21,7 +21,7 @@
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import page from 'page/page.mjs';
 import {htmlTemplate} from './gr-router_html.js';
-import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
+import {getBaseUrl} from '../../../utils/url-util.js';
 import {PatchSetBehavior} from '../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js';
 import {URLEncodingBehavior} from '../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.js';
 import {GerritNav} from '../gr-navigation/gr-navigation.js';
@@ -217,7 +217,6 @@
  * @extends PolymerElement
  */
 class GrRouter extends mixinBehaviors( [
-  BaseUrlBehavior,
   PatchSetBehavior,
   URLEncodingBehavior,
 ], GestureEventListeners(
@@ -277,7 +276,7 @@
    * @return {string}
    */
   _generateUrl(params) {
-    const base = this.getBaseUrl();
+    const base = getBaseUrl();
     let url = '';
     const Views = GerritNav.View;
 
@@ -627,7 +626,7 @@
    * @param {string} returnUrl
    */
   _redirectToLogin(returnUrl) {
-    const basePath = this.getBaseUrl() || '';
+    const basePath = getBaseUrl() || '';
     page(
         '/login/' + encodeURIComponent(returnUrl.substring(basePath.length)));
   }
@@ -725,7 +724,7 @@
   }
 
   _startRouter() {
-    const base = this.getBaseUrl();
+    const base = getBaseUrl();
     if (base) {
       page.base(base);
     }
@@ -947,7 +946,7 @@
         // See Issue 6888.
         hash = hash.replace('/ /', '/+/');
       }
-      const base = this.getBaseUrl();
+      const base = getBaseUrl();
       let newUrl = base + hash;
       if (hash.startsWith('/VE/')) {
         newUrl = base + '/settings' + hash;
@@ -1492,7 +1491,7 @@
     if (path.startsWith('/register')) { path = '/'; }
 
     if (path[0] !== '/') { return; }
-    this._redirect(this.getBaseUrl() + path);
+    this._redirect(getBaseUrl() + path);
   }
 
   /**
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.js b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.js
index b53b269..7e36823 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.js
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.js
@@ -19,6 +19,7 @@
 import './gr-router.js';
 import page from 'page/page.mjs';
 import {GerritNav} from '../gr-navigation/gr-navigation.js';
+import {stubBaseUrl} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-router');
 
@@ -838,7 +839,7 @@
             querystring: '',
             hash: '/foo/bar',
           };
-          sinon.stub(element, 'getBaseUrl').returns('/baz');
+          stubBaseUrl('/baz');
           const result = element._handleRootRoute(data);
           assert.isNotOk(result);
           assert.isTrue(redirectStub.called);
diff --git a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.js b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.js
index 01268b1..352f1d8 100644
--- a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.js
+++ b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.js
@@ -24,6 +24,7 @@
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-documentation-search_html.js';
 import {ListViewBehavior} from '../../../behaviors/gr-list-view-behavior/gr-list-view-behavior.js';
+import {getBaseUrl} from '../../../utils/url-util.js';
 
 /**
  * @extends PolymerElement
@@ -92,7 +93,7 @@
 
   _computeSearchUrl(url) {
     if (!url) { return ''; }
-    return this.getBaseUrl() + '/' + url;
+    return getBaseUrl() + '/' + url;
   }
 }
 
diff --git a/polygerrit-ui/app/elements/gr-app-element.js b/polygerrit-ui/app/elements/gr-app-element.js
index db098c5..63a7e9c 100644
--- a/polygerrit-ui/app/elements/gr-app-element.js
+++ b/polygerrit-ui/app/elements/gr-app-element.js
@@ -45,7 +45,7 @@
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-app-element_html.js';
-import {BaseUrlBehavior} from '../behaviors/base-url-behavior/base-url-behavior.js';
+import {getBaseUrl} from '../utils/url-util.js';
 import {KeyboardShortcutBehavior} from '../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
 import {GerritNav} from './core/gr-navigation/gr-navigation.js';
 import {appContext} from '../services/app-context.js';
@@ -54,7 +54,6 @@
  * @extends PolymerElement
  */
 class GrAppElement extends mixinBehaviors( [
-  BaseUrlBehavior,
   KeyboardShortcutBehavior,
 ], GestureEventListeners(
     LegacyElementMixin(
@@ -477,7 +476,7 @@
   }
 
   _updateLoginUrl() {
-    const baseUrl = this.getBaseUrl();
+    const baseUrl = getBaseUrl();
     if (baseUrl) {
       // Strip the canonical path from the path since needing canonical in
       // the path is unneeded and breaks the url.
diff --git a/polygerrit-ui/app/elements/gr-app-global-var-init.js b/polygerrit-ui/app/elements/gr-app-global-var-init.js
index 8c1161c..9961971 100644
--- a/polygerrit-ui/app/elements/gr-app-global-var-init.js
+++ b/polygerrit-ui/app/elements/gr-app-global-var-init.js
@@ -64,7 +64,8 @@
 import {GrStylesApi} from './plugins/gr-styles-api/gr-styles-api.js';
 import {pluginLoader, PluginLoader} from './shared/gr-js-api-interface/gr-plugin-loader.js';
 import {GrPluginActionContext} from './shared/gr-js-api-interface/gr-plugin-action-context.js';
-import {getBaseUrl, getPluginNameFromUrl, getRestAPI, PLUGIN_LOADING_TIMEOUT_MS, PRELOADED_PROTOCOL, send} from './shared/gr-js-api-interface/gr-api-utils.js';
+import {getPluginNameFromUrl, getRestAPI, PLUGIN_LOADING_TIMEOUT_MS, PRELOADED_PROTOCOL, send} from './shared/gr-js-api-interface/gr-api-utils.js';
+import {getBaseUrl} from '../utils/url-util.js';
 import {GerritNav} from './core/gr-navigation/gr-navigation.js';
 import {getRootElement} from '../scripts/rootElement.js';
 import {rangesEqual} from './diff/gr-diff/gr-diff-utils.js';
diff --git a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.js b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.js
index 0a84f56c..e0da53d 100644
--- a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.js
+++ b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.js
@@ -18,21 +18,18 @@
 import '../../../styles/gr-form-styles.js';
 import '../../../styles/shared-styles.js';
 import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-agreements-list_html.js';
-import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
+import {getBaseUrl} from '../../../utils/url-util.js';
 
 /**
  * @extends PolymerElement
  */
-class GrAgreementsList extends mixinBehaviors( [
-  BaseUrlBehavior,
-], GestureEventListeners(
+class GrAgreementsList extends GestureEventListeners(
     LegacyElementMixin(
-        PolymerElement))) {
+        PolymerElement)) {
   static get template() { return htmlTemplate; }
 
   static get is() { return 'gr-agreements-list'; }
@@ -56,11 +53,11 @@
   }
 
   getUrl() {
-    return this.getBaseUrl() + '/settings/new-agreement';
+    return getBaseUrl() + '/settings/new-agreement';
   }
 
   getUrlBase(item) {
-    return this.getBaseUrl() + '/' + item;
+    return getBaseUrl() + '/' + item;
   }
 }
 
diff --git a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.js b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.js
index 8359f96..023eee8 100644
--- a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.js
+++ b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.js
@@ -20,21 +20,18 @@
 import '../../../styles/shared-styles.js';
 import '../../shared/gr-button/gr-button.js';
 import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-cla-view_html.js';
-import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
+import {getBaseUrl} from '../../../utils/url-util.js';
 
 /**
  * @extends PolymerElement
  */
-class GrClaView extends mixinBehaviors( [
-  BaseUrlBehavior,
-], GestureEventListeners(
+class GrClaView extends GestureEventListeners(
     LegacyElementMixin(
-        PolymerElement))) {
+        PolymerElement)) {
   static get template() { return htmlTemplate; }
 
   static get is() { return 'gr-cla-view'; }
@@ -91,7 +88,7 @@
     if (configUrl.startsWith('http:') || configUrl.startsWith('https:')) {
       url = configUrl;
     } else {
-      url = this.getBaseUrl() + '/' + configUrl;
+      url = getBaseUrl() + '/' + configUrl;
     }
 
     return url;
diff --git a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.js b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.js
index d0d30ea..eee06e3 100644
--- a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.js
+++ b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.js
@@ -20,12 +20,11 @@
 import '../../shared/gr-button/gr-button.js';
 import '../../shared/gr-overlay/gr-overlay.js';
 import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-identities_html.js';
-import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
+import {getBaseUrl} from '../../../utils/url-util.js';
 
 const AUTH = [
   'OPENID',
@@ -35,11 +34,9 @@
 /**
  * @extends PolymerElement
  */
-class GrIdentities extends mixinBehaviors( [
-  BaseUrlBehavior,
-], GestureEventListeners(
+class GrIdentities extends GestureEventListeners(
     LegacyElementMixin(
-        PolymerElement))) {
+        PolymerElement)) {
   static get template() { return htmlTemplate; }
 
   static get is() { return 'gr-identities'; }
@@ -106,7 +103,7 @@
   }
 
   _computeLinkAnotherIdentity() {
-    const baseUrl = this.getBaseUrl() || '';
+    const baseUrl = getBaseUrl() || '';
     let pathname = window.location.pathname;
     if (baseUrl) {
       pathname = '/' + pathname.substring(baseUrl.length);
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js
index 3b889c4..c1ca460 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js
@@ -45,7 +45,7 @@
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-settings-view_html.js';
-import {DocsUrlBehavior} from '../../../behaviors/docs-url-behavior/docs-url-behavior.js';
+import {getDocsBaseUrl} from '../../../utils/url-util.js';
 import {ChangeTableBehavior} from '../../../behaviors/gr-change-table-behavior/gr-change-table-behavior.js';
 
 const PREFS_SECTION_FIELDS = [
@@ -78,7 +78,6 @@
  * @extends PolymerElement
  */
 class GrSettingsView extends mixinBehaviors( [
-  DocsUrlBehavior,
   ChangeTableBehavior,
 ], GestureEventListeners(
     LegacyElementMixin(
@@ -237,7 +236,7 @@
       }
 
       configPromises.push(
-          this.getDocsBaseUrl(config, this.$.restAPI)
+          getDocsBaseUrl(config, this.$.restAPI)
               .then(baseUrl => { this._docsBaseUrl = baseUrl; }));
 
       return Promise.all(configPromises);
diff --git a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.js b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.js
index 3844bea..eff1953 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.js
+++ b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.js
@@ -17,22 +17,18 @@
 
 import '../gr-account-label/gr-account-label.js';
 import '../../../styles/shared-styles.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-account-link_html.js';
-import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 
 /**
  * @extends PolymerElement
  */
-class GrAccountLink extends mixinBehaviors( [
-  BaseUrlBehavior,
-], GestureEventListeners(
+class GrAccountLink extends GestureEventListeners(
     LegacyElementMixin(
-        PolymerElement))) {
+        PolymerElement)) {
   static get template() { return htmlTemplate; }
 
   static get is() { return 'gr-account-link'; }
diff --git a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.js b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.js
index 1385f6d..0d30179 100644
--- a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.js
+++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.js
@@ -17,22 +17,19 @@
 import '../../../styles/shared-styles.js';
 import '../gr-js-api-interface/gr-js-api-interface.js';
 import '../gr-rest-api-interface/gr-rest-api-interface.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-avatar_html.js';
-import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
+import {getBaseUrl} from '../../../utils/url-util.js';
 import {pluginLoader} from '../gr-js-api-interface/gr-plugin-loader.js';
 
 /**
  * @extends PolymerElement
  */
-class GrAvatar extends mixinBehaviors( [
-  BaseUrlBehavior,
-], GestureEventListeners(
+class GrAvatar extends GestureEventListeners(
     LegacyElementMixin(
-        PolymerElement))) {
+        PolymerElement)) {
   static get template() { return htmlTemplate; }
 
   static get is() { return 'gr-avatar'; }
@@ -101,7 +98,7 @@
         return avatars[i].url;
       }
     }
-    return this.getBaseUrl() + '/accounts/' +
+    return getBaseUrl() + '/accounts/' +
       encodeURIComponent(this._getAccounts(account)) +
       '/avatar?s=' + this.imageSize;
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js
index 717275d..0828bf6 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js
@@ -26,7 +26,7 @@
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-dropdown_html.js';
-import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
+import {getBaseUrl} from '../../../utils/url-util.js';
 import {KeyboardShortcutBehavior} from '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
 
 const REL_NOOPENER = 'noopener';
@@ -36,7 +36,6 @@
  * @extends PolymerElement
  */
 class GrDropdown extends mixinBehaviors( [
-  BaseUrlBehavior,
   KeyboardShortcutBehavior,
 ], GestureEventListeners(
     LegacyElementMixin(
@@ -233,8 +232,8 @@
    * @return {!string} The scheme-relative URL.
    */
   _computeURLHelper(host, path) {
-    const base = path.startsWith(this.getBaseUrl()) ?
-      '' : this.getBaseUrl();
+    const base = path.startsWith(getBaseUrl()) ?
+      '' : getBaseUrl();
     return '//' + host + base + path;
   }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.js
index 81ece81..9bef3a2 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.js
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 
-import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
+import {getBaseUrl} from '../../../utils/url-util.js';
 
 export const PRELOADED_PROTOCOL = 'preloaded:';
 export const PLUGIN_LOADING_TIMEOUT_MS = 10000;
@@ -28,10 +28,6 @@
   return _restAPI;
 }
 
-export function getBaseUrl() {
-  return BaseUrlBehavior.getBaseUrl();
-}
-
 /**
  * Retrieves the name of the plugin base on the url.
  *
@@ -49,7 +45,7 @@
   if (url.protocol === PRELOADED_PROTOCOL) {
     return url.pathname;
   }
-  const base = BaseUrlBehavior.getBaseUrl();
+  const base = getBaseUrl();
   let pathname = url.pathname.replace(base, '');
   // Load from ASSETS_PATH
   if (window.ASSETS_PATH && url.href.includes(window.ASSETS_PATH)) {
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.js
index 69d635b..946325f 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.js
@@ -17,13 +17,13 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-js-api-interface.js';
-import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
 import {GrPopupInterface} from '../../plugins/gr-popup-interface/gr-popup-interface.js';
 import {GrSettingsApi} from '../../plugins/gr-settings-api/gr-settings-api.js';
 import {GrPluginActionContext} from './gr-plugin-action-context.js';
 import {PLUGIN_LOADING_TIMEOUT_MS} from './gr-api-utils.js';
 import {pluginLoader} from './gr-plugin-loader.js';
 import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
+import {stubBaseUrl} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-js-api-interface');
 
@@ -371,7 +371,7 @@
     let baseUrlPlugin;
 
     setup(() => {
-      sinon.stub(BaseUrlBehavior, 'getBaseUrl').returns('/r');
+      stubBaseUrl('/r');
 
       pluginApi.install(p => { baseUrlPlugin = p; }, '0.1',
           'http://test.com/r/plugins/baseurlplugin/static/test.js');
@@ -460,7 +460,7 @@
 
   suite('screen', () => {
     test('screenUrl()', () => {
-      sinon.stub(BaseUrlBehavior, 'getBaseUrl').returns('/base');
+      stubBaseUrl('/base');
       assert.equal(
           plugin.screenUrl(),
           `${location.origin}/base/x/testplugin`
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.js
index fed91de..0bf49c3 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.js
@@ -23,9 +23,10 @@
   PLUGIN_LOADING_TIMEOUT_MS,
   PRELOADED_PROTOCOL,
   getPluginNameFromUrl,
-  getBaseUrl,
 } from './gr-api-utils.js';
 
+import {getBaseUrl} from '../../../utils/url-util.js';
+
 /**
  * @enum {string}
  */
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.js
index e8839d6..1e6938e 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.js
@@ -17,10 +17,9 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-js-api-interface.js';
-import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
 import {PRELOADED_PROTOCOL, PLUGIN_LOADING_TIMEOUT_MS} from './gr-api-utils.js';
 import {_testOnly_resetPluginLoader} from './gr-plugin-loader.js';
-import {resetPlugins} from '../../../test/test-utils.js';
+import {resetPlugins, stubBaseUrl} from '../../../test/test-utils.js';
 import {_testOnly_flushPreinstalls} from './gr-gerrit.js';
 import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
 
@@ -348,7 +347,7 @@
 
     test('relative path should honor getBaseUrl', () => {
       const testUrl = '/test';
-      sinon.stub(BaseUrlBehavior, 'getBaseUrl').callsFake(() => testUrl);
+      stubBaseUrl(testUrl);
 
       pluginLoader.loadPlugins([
         'foo/bar.js',
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js
index 9d79462..446ceb5 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 
-import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
+import {getBaseUrl} from '../../../utils/url-util.js';
 import {GrAttributeHelper} from '../../plugins/gr-attribute-helper/gr-attribute-helper.js';
 import {GrChangeActionsInterface} from './gr-change-actions-js-api.js';
 import {GrChangeReplyInterface} from './gr-change-reply-js-api.js';
@@ -152,7 +152,7 @@
   Plugin.prototype.url = function(opt_path) {
     const relPath = '/plugins/' + this._name + (opt_path || '/');
     const sameOriginPath = window.location.origin +
-      `${BaseUrlBehavior.getBaseUrl()}${relPath}`;
+      `${getBaseUrl()}${relPath}`;
     if (window.location.origin === this._url.origin) {
       // Plugin loaded from the same origin as gr-app, getBaseUrl in effect.
       return sameOriginPath;
@@ -168,7 +168,7 @@
 
   Plugin.prototype.screenUrl = function(opt_screenName) {
     const origin = location.origin;
-    const base = BaseUrlBehavior.getBaseUrl();
+    const base = getBaseUrl();
     const tokenPart = opt_screenName ? '/' + opt_screenName : '';
     return `${origin}${base}/x/${this.getPluginName()}${tokenPart}`;
   };
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.js b/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.js
index 6f8a88a..f49cf0f 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.js
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.js
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 
-import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
+import {getBaseUrl} from '../../../utils/url-util.js';
 
 /**
  * Pattern describing URLs with supported protocols.
@@ -43,7 +43,7 @@
   this.linkConfig = linkConfig;
   this.callback = callback;
   this.removeZeroWidthSpace = opt_removeZeroWidthSpace;
-  this.baseUrl = BaseUrlBehavior.getBaseUrl();
+  this.baseUrl = getBaseUrl();
   Object.preventExtensions(this);
 }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.js b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.js
index 595694c..6e8b3a3 100644
--- a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.js
+++ b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.js
@@ -23,7 +23,7 @@
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-list-view_html.js';
-import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
+import {getBaseUrl} from '../../../utils/url-util.js';
 import {URLEncodingBehavior} from '../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.js';
 import page from 'page/page.mjs';
 
@@ -33,7 +33,6 @@
  * @extends PolymerElement
  */
 class GrListView extends mixinBehaviors( [
-  BaseUrlBehavior,
   URLEncodingBehavior,
 ], GestureEventListeners(
     LegacyElementMixin(
@@ -91,7 +90,7 @@
     // Offset could be a string when passed from the router.
     offset = +(offset || 0);
     const newOffset = Math.max(0, offset + (itemsPerPage * direction));
-    let href = this.getBaseUrl() + path;
+    let href = getBaseUrl() + path;
     if (filter) {
       href += '/q/filter:' + this.encodeURL(filter, false);
     }
diff --git a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.js b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.js
index 93451ff..7782629 100644
--- a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.js
@@ -18,6 +18,7 @@
 import '../../../test/common-test-setup-karma.js';
 import './gr-list-view.js';
 import page from 'page/page.mjs';
+import {stubBaseUrl} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-list-view');
 
@@ -34,7 +35,7 @@
     let filter = 'test';
     const path = '/admin/projects';
 
-    sinon.stub(element, 'getBaseUrl').callsFake(() => '');
+    stubBaseUrl('');
 
     assert.equal(
         element._computeNavLink(offset, 1, projectsPerPage, filter, path),
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-auth.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-auth.js
index 50a837d..5fcd1a4 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-auth.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-auth.js
@@ -14,7 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
+import {getBaseUrl} from '../../../utils/url-util.js';
 import {appContext} from '../../../services/app-context.js';
 
 const MAX_AUTH_CHECK_WAIT_TIME_MS = 1000 * 30; // 30s
@@ -38,7 +38,7 @@
   }
 
   get baseUrl() {
-    return BaseUrlBehavior.getBaseUrl();
+    return getBaseUrl();
   }
 
   /**
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-auth_test.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-auth_test.js
index af2efae..efdd4d1 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-auth_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-auth_test.js
@@ -16,9 +16,9 @@
  */
 
 import '../../../test/common-test-setup-karma.js';
-import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
 import {Auth, authService} from './gr-auth.js';
 import {appContext} from '../../../services/app-context.js';
+import {stubBaseUrl} from '../../../test/test-utils.js';
 
 suite('gr-auth', () => {
   let auth;
@@ -260,7 +260,7 @@
 
     test('base url support', done => {
       const baseUrl = 'http://foo';
-      sinon.stub(BaseUrlBehavior, 'getBaseUrl').returns(baseUrl);
+      stubBaseUrl(baseUrl);
       auth.fetch(baseUrl + '/url', {bar: 'bar'}).then(() => {
         const [url] = fetch.lastCall.args;
         assert.equal(url, 'http://foo/a/url?access_token=zbaz');
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
index 3c76ee1..60f1463 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
@@ -28,6 +28,7 @@
 import {GrReviewerUpdatesParser} from './gr-reviewer-updates-parser.js';
 import {parseDate} from '../../../utils/date-util.js';
 import {authService} from './gr-auth.js';
+import {getBaseUrl} from '../../../utils/url-util.js';
 
 const DiffViewMode = {
   SIDE_BY_SIDE: 'SIDE_BY_SIDE',
@@ -2271,7 +2272,7 @@
   }
 
   _fetchB64File(url) {
-    return this._restApiHelper.fetch({url: this.getBaseUrl() + url})
+    return this._restApiHelper.fetch({url: getBaseUrl() + url})
         .then(response => {
           if (!response.ok) {
             return Promise.reject(new Error(response.statusText));
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.js
index bc70791..d54d342 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.js
@@ -14,6 +14,8 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+import {getBaseUrl} from '../../../../utils/url-util.js';
+
 const JSON_PREFIX = ')]}\'';
 
 /**
@@ -237,7 +239,7 @@
    * @return {string}
    */
   urlWithParams(url, opt_params) {
-    if (!opt_params) { return this.getBaseUrl() + url; }
+    if (!opt_params) { return getBaseUrl() + url; }
 
     const params = [];
     for (const p in opt_params) {
@@ -250,7 +252,7 @@
         params.push(`${encodeURIComponent(p)}=${encodeURIComponent(value)}`);
       }
     }
-    return this.getBaseUrl() + url + '?' + params.join('&');
+    return getBaseUrl() + url + '?' + params.join('&');
   }
 
   /**
@@ -299,10 +301,6 @@
     return req;
   }
 
-  getBaseUrl() {
-    return this._restApiInterface.getBaseUrl();
-  }
-
   dispatchEvent(type, detail) {
     return this._restApiInterface.dispatchEvent(type, detail);
   }
@@ -358,7 +356,7 @@
       }
     }
     const url = req.url.startsWith('http') ?
-      req.url : this.getBaseUrl() + req.url;
+      req.url : getBaseUrl() + req.url;
     const fetchReq = {
       url,
       fetchOptions: options,
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.js
index 8acba73..9cac375 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.js
@@ -35,7 +35,6 @@
     window.CANONICAL_PATH = 'testhelper';
 
     const mockRestApiInterface = {
-      getBaseUrl: sinon.stub().returns(window.CANONICAL_PATH),
       fire: sinon.stub(),
     };
 
diff --git a/polygerrit-ui/app/polymer.json b/polygerrit-ui/app/polymer.json
index affa7f2..af927a0 100644
--- a/polygerrit-ui/app/polymer.json
+++ b/polygerrit-ui/app/polymer.json
@@ -8,7 +8,7 @@
     "types/**/*"
   ],
   "lint": {
-    "rules": ["polymer-2"],
+    "rules": ["polymer-3"],
     "ignoreWarnings": ["deprecated-dom-call"]
   }
 }
diff --git a/polygerrit-ui/app/test/common-test-setup.js b/polygerrit-ui/app/test/common-test-setup.js
index 36e27ba..9ac05f9 100644
--- a/polygerrit-ui/app/test/common-test-setup.js
+++ b/polygerrit-ui/app/test/common-test-setup.js
@@ -26,7 +26,7 @@
 import {_testOnly_resetPluginLoader} from '../elements/shared/gr-js-api-interface/gr-plugin-loader.js';
 import {_testOnlyResetRestApi} from '../elements/shared/gr-js-api-interface/gr-plugin-rest-api.js';
 import {_testOnlyResetGrRestApiSharedObjects} from '../elements/shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import {TestKeyboardShortcutBinder} from './test-utils';
+import {cleanupTestUtils, TestKeyboardShortcutBinder} from './test-utils.js';
 import {flushDebouncers} from '@polymer/polymer/lib/utils/debounce';
 import {_testOnly_getShortcutManagerInstance} from '../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
 import sinon from 'sinon/pkg/sinon-esm.js';
@@ -136,6 +136,7 @@
 
 teardown(() => {
   sinon.restore();
+  cleanupTestUtils();
   cleanups.forEach(cleanup => cleanup());
   cleanups.splice(0);
   TestKeyboardShortcutBinder.pop();
diff --git a/polygerrit-ui/app/test/test-utils.js b/polygerrit-ui/app/test/test-utils.js
index aaab295..5be9850 100644
--- a/polygerrit-ui/app/test/test-utils.js
+++ b/polygerrit-ui/app/test/test-utils.js
@@ -69,3 +69,19 @@
   const pl = _testOnly_resetPluginLoader();
   pl.loadPlugins([]);
 };
+
+const cleanups = [];
+
+function registerTestCleanup(cleanupCallback) {
+  cleanups.push(cleanupCallback);
+}
+
+export function cleanupTestUtils() {
+  cleanups.forEach(cleanup => cleanup());
+}
+
+export function stubBaseUrl(newUrl) {
+  const originalCanonicalPath = window.CANONICAL_PATH;
+  window.CANONICAL_PATH = newUrl;
+  registerTestCleanup(() => window.CANONICAL_PATH = originalCanonicalPath);
+}
diff --git a/polygerrit-ui/app/tsconfig_eslint.json b/polygerrit-ui/app/tsconfig_eslint.json
new file mode 100644
index 0000000..7cc99c7
--- /dev/null
+++ b/polygerrit-ui/app/tsconfig_eslint.json
@@ -0,0 +1,11 @@
+{
+  "extends": "./tsconfig.json",
+  "compilerOptions": {
+    "skipLibCheck": false, /* This is required for report-ts-error.js.
+    See details in the //tools/js/eslint-rules/report-ts-error.js file.*/
+    "baseUrl": "../../external/ui_npm/node_modules" /* Only for bazel.
+    Compiler will try to use it to resolve module name and if it fail - will
+    fallback to a default behavior
+    (https://github.com/microsoft/TypeScript/issues/5039)*/
+  }
+}
diff --git a/polygerrit-ui/app/utils/async-util.js b/polygerrit-ui/app/utils/async-util.js
new file mode 100644
index 0000000..14d0288
--- /dev/null
+++ b/polygerrit-ui/app/utils/async-util.js
@@ -0,0 +1,38 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * @template T
+ * @param {!Array<T>} array
+ * @param {!Function} fn An iteratee function to be passed each element of
+ *     the array in order. Must return a promise, and the following
+ *     iteration will not begin until resolution of the promise returned by
+ *     the previous iteration.
+ *
+ *     An optional second argument to fn is a callback that will halt the
+ *     loop if called.
+ * @return {!Promise<undefined>}
+ */
+export function asyncForeach(array, fn) {
+  if (!array.length) { return Promise.resolve(); }
+  let stop = false;
+  const stopCallback = () => { stop = true; };
+  return fn(array[0], stopCallback).then(exit => {
+    if (stop) { return Promise.resolve(); }
+    return asyncForeach(array.slice(1), fn);
+  });
+}
diff --git a/polygerrit-ui/app/behaviors/async-foreach-behavior/async-foreach-behavior_test.js b/polygerrit-ui/app/utils/async-util_test.js
similarity index 81%
rename from polygerrit-ui/app/behaviors/async-foreach-behavior/async-foreach-behavior_test.js
rename to polygerrit-ui/app/utils/async-util_test.js
index 0a44884..df29e97 100644
--- a/polygerrit-ui/app/behaviors/async-foreach-behavior/async-foreach-behavior_test.js
+++ b/polygerrit-ui/app/utils/async-util_test.js
@@ -15,12 +15,13 @@
  * limitations under the License.
  */
 
-import '../../test/common-test-setup-karma.js';
-import {AsyncForeachBehavior} from './async-foreach-behavior.js';
-suite('async-foreach-behavior tests', () => {
+import '../test/common-test-setup-karma.js';
+import {asyncForeach} from './async-util.js';
+
+suite('async-util tests', () => {
   test('loops over each item', () => {
     const fn = sinon.stub().returns(Promise.resolve());
-    return AsyncForeachBehavior.asyncForeach([1, 2, 3], fn)
+    return asyncForeach([1, 2, 3], fn)
         .then(() => {
           assert.isTrue(fn.calledThrice);
           assert.equal(fn.getCall(0).args[0], 1);
@@ -36,7 +37,7 @@
       stop();
       return Promise.resolve();
     };
-    return AsyncForeachBehavior.asyncForeach([1, 2, 3], fn)
+    return asyncForeach([1, 2, 3], fn)
         .then(() => {
           assert.isTrue(stub.calledOnce);
           assert.equal(stub.lastCall.args[0], 1);
diff --git a/polygerrit-ui/app/utils/url-util.ts b/polygerrit-ui/app/utils/url-util.ts
new file mode 100644
index 0000000..a823bb4
--- /dev/null
+++ b/polygerrit-ui/app/utils/url-util.ts
@@ -0,0 +1,72 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+const PROBE_PATH = '/Documentation/index.html';
+const DOCS_BASE_PATH = '/Documentation';
+
+// NOTE: Below we define 2 types (DocUrlBehaviorConfig and RestApi) to avoid
+// type 'any'. These are temporary definitions and they must be
+// updated/moved/removed when we start converting our codebase to typescript.
+// Right now we are using these types here just for adding typescript support to
+// our build/test infrastructure. Doing so we avoid massive code updates at this
+// stage.
+
+// TODO: introduce global gerrit config type instead of DocUrlBehaviorConfig.
+// The DocUrlBehaviorConfig is a temporary type
+interface DocUrlBehaviorConfig {
+  gerrit?: {doc_url?: string};
+}
+
+// TODO: implement RestApi type correctly and remove interface from this file
+interface RestApi {
+  probePath(url: string): Promise<boolean>;
+}
+
+export function getBaseUrl(): string {
+  return window.CANONICAL_PATH || '';
+}
+
+let getDocsBaseUrlCachedPromise: Promise<string | null> | undefined;
+
+/**
+ * Get the docs base URL from either the server config or by probing.
+ *
+ * @param {Object} config The server config.
+ * @param {!Object} restApi A REST API instance
+ * @return {!Promise<string>} A promise that resolves with the docs base
+ *     URL.
+ */
+export function getDocsBaseUrl(
+  config: DocUrlBehaviorConfig,
+  restApi: RestApi
+): Promise<string | null> {
+  if (!getDocsBaseUrlCachedPromise) {
+    getDocsBaseUrlCachedPromise = new Promise(resolve => {
+      if (config && config.gerrit && config.gerrit.doc_url) {
+        resolve(config.gerrit.doc_url);
+      } else {
+        restApi.probePath(getBaseUrl() + PROBE_PATH).then(ok => {
+          resolve(ok ? getBaseUrl() + DOCS_BASE_PATH : null);
+        });
+      }
+    });
+  }
+  return getDocsBaseUrlCachedPromise;
+}
+
+export function _testOnly_clearDocsBaseUrlCache() {
+  getDocsBaseUrlCachedPromise = undefined;
+}
diff --git a/polygerrit-ui/app/utils/url-util_test.js b/polygerrit-ui/app/utils/url-util_test.js
new file mode 100644
index 0000000..d3d3f2f
--- /dev/null
+++ b/polygerrit-ui/app/utils/url-util_test.js
@@ -0,0 +1,85 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../test/common-test-setup-karma.js';
+import {getBaseUrl, getDocsBaseUrl, _testOnly_clearDocsBaseUrlCache} from './url-util.js';
+
+suite('url-util tests', () => {
+  suite('getBaseUrl tests', () => {
+    let originialCanonicalPath;
+
+    suiteSetup(() => {
+      originialCanonicalPath = window.CANONICAL_PATH;
+      window.CANONICAL_PATH = '/r';
+    });
+
+    suiteTeardown(() => {
+      window.CANONICAL_PATH = originialCanonicalPath;
+    });
+
+    test('getBaseUrl', () => {
+      assert.deepEqual(getBaseUrl(), '/r');
+    });
+  });
+
+  suite('getDocsBaseUrl tests', () => {
+    setup(() => {
+      _testOnly_clearDocsBaseUrlCache();
+    });
+
+    test('null config', async () => {
+      const mockRestApi = {
+        probePath: sinon.stub().returns(Promise.resolve(true)),
+      };
+      const docsBaseUrl = await getDocsBaseUrl(null, mockRestApi);
+      assert.isTrue(
+          mockRestApi.probePath.calledWith('/Documentation/index.html'));
+      assert.equal(docsBaseUrl, '/Documentation');
+    });
+
+    test('no doc config', async () => {
+      const mockRestApi = {
+        probePath: sinon.stub().returns(Promise.resolve(true)),
+      };
+      const config = {gerrit: {}};
+      const docsBaseUrl = await getDocsBaseUrl(config, mockRestApi);
+      assert.isTrue(
+          mockRestApi.probePath.calledWith('/Documentation/index.html'));
+      assert.equal(docsBaseUrl, '/Documentation');
+    });
+
+    test('has doc config', async () => {
+      const mockRestApi = {
+        probePath: sinon.stub().returns(Promise.resolve(true)),
+      };
+      const config = {gerrit: {doc_url: 'foobar'}};
+      const docsBaseUrl = await getDocsBaseUrl(config, mockRestApi);
+      assert.isFalse(mockRestApi.probePath.called);
+      assert.equal(docsBaseUrl, 'foobar');
+    });
+
+    test('no probe', async () => {
+      const mockRestApi = {
+        probePath: sinon.stub().returns(Promise.resolve(false)),
+      };
+      const docsBaseUrl = await getDocsBaseUrl(null, mockRestApi);
+      assert.isTrue(
+          mockRestApi.probePath.calledWith('/Documentation/index.html'));
+      assert.isNotOk(docsBaseUrl);
+    });
+  });
+});
diff --git a/proto/cache.proto b/proto/cache.proto
index e157608..4d24036 100644
--- a/proto/cache.proto
+++ b/proto/cache.proto
@@ -323,3 +323,18 @@
   repeated ProjectWatchProto project_watch_proto = 2;
   string user_preferences = 3;
 }
+
+// Serialized form of com.google.gerrit.entities.Project.
+// Next ID: 11
+message ProjectProto {
+  string name = 1;
+  string description = 2;
+  map<string, string> boolean_configs = 3;
+  string submit_type = 4; // ENUM as String
+  string state = 5; // ENUM as String
+  string parent = 6;
+  string max_object_size_limit = 7;
+  string default_dashboard = 8;
+  string local_default_dashboard = 9;
+  string config_ref_state = 10;
+}
diff --git a/tools/js/eslint-rules/goog-module-id.js b/tools/js/eslint-rules/goog-module-id.js
index 272e664..56cd645 100644
--- a/tools/js/eslint-rules/goog-module-id.js
+++ b/tools/js/eslint-rules/goog-module-id.js
@@ -106,7 +106,8 @@
     }
     const expectedName = 'polygerrit.' +
         filename.slice(index + pathStart.length, -jsExt.length)
-            .replace('/', '.');
+            .replace(/\//g, '.') // Replace all occurrences of '/' with '.'
+            .replace(/-/g, '$2d'); // Replace all occurrences of '-' with '$2d'
     if(argument.value !== expectedName) {
       context.report({
         message: `Invalid module id. It must be '${expectedName}'.`,
diff --git a/tools/js/eslint-rules/report-ts-error.js b/tools/js/eslint-rules/report-ts-error.js
new file mode 100644
index 0000000..48dddf4
--- /dev/null
+++ b/tools/js/eslint-rules/report-ts-error.js
@@ -0,0 +1,101 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// While we are migrating to typescript, gerrit can have .d.ts files.
+// The option "skipLibCheck" is set to true  In the tsconfig.json.
+// This is required, because we want to skip type checking in node_modules
+// directory - some .d.ts files in 3rd-party modules are incorrect.
+// Unfortunately, this options also excludes our own .d.ts files from type
+// checking. This rule reports all .ts errors in a file as tslint errors.
+
+function getMassageTextFromChain(chainNode, prefix) {
+  let nestedMessages = prefix + chainNode.messageText;
+  if (chainNode.next && chainNode.next.length > 0) {
+    nestedMessages += "\n";
+    for (const node of chainNode.next) {
+      nestedMessages +=
+          getMassageTextFromChain(node, prefix + " ");
+      if(!nestedMessages.endsWith('\n')) {
+        nestedMessages += "\n";
+      }
+    }
+  }
+  return nestedMessages;
+}
+
+function getMessageText(diagnostic) {
+  if (typeof diagnostic.messageText === 'string') {
+    return diagnostic.messageText;
+  }
+  return getMassageTextFromChain(diagnostic.messageText, "");
+}
+
+function getDiagnosticStartAndEnd(diagnostic) {
+  if(diagnostic.start) {
+    const file = diagnostic.file;
+    const start = file.getLineAndCharacterOfPosition(diagnostic.start);
+    const length = diagnostic.length ? diagnostic.length : 0;
+    return {
+      start,
+      end: file.getLineAndCharacterOfPosition(diagnostic.start + length),
+    };
+  }
+  return {
+    start: {line:0, character: 0},
+    end: {line:0, character: 0},
+  }
+}
+
+module.exports = {
+  meta: {
+    type: "problem",
+    docs: {
+      description: "Reports all typescript problems as linter problems",
+      category: ".d.ts",
+      recommended: false
+    },
+    schema: [],
+  },
+  create: function (context) {
+    const program = context.parserServices.program;
+    return {
+      Program: function(node) {
+        const sourceFile =
+            context.parserServices.esTreeNodeToTSNodeMap.get(node);
+        const allDiagnostics = [
+            ...program.getDeclarationDiagnostics(sourceFile),
+            ...program.getSemanticDiagnostics(sourceFile)];
+        for(const diagnostic of allDiagnostics) {
+          const {start, end } = getDiagnosticStartAndEnd(diagnostic);
+          context.report({
+            message: getMessageText(diagnostic),
+            loc: {
+              start: {
+                line: start.line + 1,
+                column: start.character,
+              },
+              end: {
+                line: end.line + 1,
+                column: end.character,
+              }
+            }
+          });
+        }
+      },
+    };
+  }
+};