diff --git a/api/src/org/labkey/api/assay/plate/AssayPlateMetadataService.java b/api/src/org/labkey/api/assay/plate/AssayPlateMetadataService.java
index 51418b27794..afd647ac38a 100644
--- a/api/src/org/labkey/api/assay/plate/AssayPlateMetadataService.java
+++ b/api/src/org/labkey/api/assay/plate/AssayPlateMetadataService.java
@@ -25,6 +25,7 @@ public interface AssayPlateMetadataService
 {
     String PLATE_TEMPLATE_COLUMN_NAME = "PlateTemplate";
     Map<AssayDataType, AssayPlateMetadataService> _handlers = new HashMap<>();
+    String EXPERIMENTAL_APP_PLATE_SUPPORT = "experimental-app-plate-support";
 
     static void registerService(AssayDataType dataType, AssayPlateMetadataService handler)
     {
@@ -38,7 +39,7 @@ static void registerService(AssayDataType dataType, AssayPlateMetadataService ha
 
     static boolean isExperimentalAppPlateEnabled()
     {
-        return ExperimentalFeatureService.get().isFeatureEnabled("experimental-app-plate-support");
+        return ExperimentalFeatureService.get().isFeatureEnabled(EXPERIMENTAL_APP_PLATE_SUPPORT);
     }
 
     @Nullable
diff --git a/assay/api-src/org/labkey/api/assay/plate/Plate.java b/assay/api-src/org/labkey/api/assay/plate/Plate.java
index cf0cb83302d..6225d1379ae 100644
--- a/assay/api-src/org/labkey/api/assay/plate/Plate.java
+++ b/assay/api-src/org/labkey/api/assay/plate/Plate.java
@@ -16,6 +16,7 @@
 
 package org.labkey.api.assay.plate;
 
+import org.jetbrains.annotations.NotNull;
 import org.jetbrains.annotations.Nullable;
 import org.labkey.api.exp.Identifiable;
 import org.labkey.api.study.PropertySet;
@@ -43,6 +44,8 @@ public interface Plate extends PropertySet, Identifiable
 
     Well getWell(int rowId);
 
+    List<Well> getWells();
+
     WellGroup getWellGroup(WellGroup.Type type, String wellGroupName);
 
     @Nullable WellGroup getWellGroup(int rowId);
@@ -71,4 +74,17 @@ public interface Plate extends PropertySet, Identifiable
 
     @Override
     @Nullable ActionURL detailsURL();
+
+    /**
+     * The list of metadata fields that are configured for this plate
+     */
+    @NotNull List<PlateCustomField> getCustomFields();
+
+    Plate copy();
+
+    /**
+     * Returns the domain ID for the plate metadata domain.
+     */
+    @Nullable
+    Integer getMetadataDomainId();
 }
diff --git a/assay/src/org/labkey/assay/plate/model/PlateCustomField.java b/assay/api-src/org/labkey/api/assay/plate/PlateCustomField.java
similarity index 97%
rename from assay/src/org/labkey/assay/plate/model/PlateCustomField.java
rename to assay/api-src/org/labkey/api/assay/plate/PlateCustomField.java
index 2bf207e2b8f..b5aa8b3a022 100644
--- a/assay/src/org/labkey/assay/plate/model/PlateCustomField.java
+++ b/assay/api-src/org/labkey/api/assay/plate/PlateCustomField.java
@@ -1,4 +1,4 @@
-package org.labkey.assay.plate.model;
+package org.labkey.api.assay.plate;
 
 import com.fasterxml.jackson.annotation.JsonInclude;
 import org.labkey.api.exp.property.DomainProperty;
diff --git a/assay/api-src/org/labkey/api/assay/plate/PlateService.java b/assay/api-src/org/labkey/api/assay/plate/PlateService.java
index fbc1967347a..154f227b2b3 100644
--- a/assay/api-src/org/labkey/api/assay/plate/PlateService.java
+++ b/assay/api-src/org/labkey/api/assay/plate/PlateService.java
@@ -25,6 +25,8 @@
 import org.labkey.api.data.statistics.StatsService;
 import org.labkey.api.exp.Lsid;
 import org.labkey.api.exp.api.ExpRun;
+import org.labkey.api.exp.property.Domain;
+import org.labkey.api.query.ValidationException;
 import org.labkey.api.security.User;
 import org.labkey.api.services.ServiceRegistry;
 import org.labkey.api.view.ActionURL;
@@ -236,6 +238,11 @@ static PlateService get()
      */
     TableInfo getPlateTableInfo();
 
+    /**
+     * Create the plate metadata domain for this container.
+     */
+    @NotNull Domain ensurePlateMetadataDomain(Container container, User user) throws ValidationException;
+
     /**
      * A PlateDetailsResolver implementation provides a URL where a detailed, plate-type specific
      * UI can be found.
diff --git a/assay/api-src/org/labkey/api/assay/plate/PositionImpl.java b/assay/api-src/org/labkey/api/assay/plate/PositionImpl.java
index 9734ec43b53..fb2943ea206 100644
--- a/assay/api-src/org/labkey/api/assay/plate/PositionImpl.java
+++ b/assay/api-src/org/labkey/api/assay/plate/PositionImpl.java
@@ -78,6 +78,12 @@ public int getColumn()
         return _column;
     }
 
+    // needed for DB serialization
+    public int getCol()
+    {
+        return getColumn();
+    }
+
     @Override
     public int getRow()
     {
@@ -143,11 +149,6 @@ public void setCol(int column)
         setColumn(column);
     }
 
-    public int getCol()
-    {
-        return getColumn();
-    }
-
     public Integer getPlateId()
     {
         return _plateId;
diff --git a/assay/src/org/labkey/assay/plate/model/WellCustomField.java b/assay/api-src/org/labkey/api/assay/plate/WellCustomField.java
similarity index 92%
rename from assay/src/org/labkey/assay/plate/model/WellCustomField.java
rename to assay/api-src/org/labkey/api/assay/plate/WellCustomField.java
index 224d5e5f322..f13ea45aa42 100644
--- a/assay/src/org/labkey/assay/plate/model/WellCustomField.java
+++ b/assay/api-src/org/labkey/api/assay/plate/WellCustomField.java
@@ -1,4 +1,4 @@
-package org.labkey.assay.plate.model;
+package org.labkey.api.assay.plate;
 
 import org.labkey.api.exp.property.DomainProperty;
 
diff --git a/assay/src/org/labkey/assay/AssayModule.java b/assay/src/org/labkey/assay/AssayModule.java
index 93c85497696..02cdfbbea8b 100644
--- a/assay/src/org/labkey/assay/AssayModule.java
+++ b/assay/src/org/labkey/assay/AssayModule.java
@@ -54,6 +54,7 @@
 import org.labkey.api.security.User;
 import org.labkey.api.security.permissions.ReadPermission;
 import org.labkey.api.security.roles.RoleManager;
+import org.labkey.api.settings.AdminConsole;
 import org.labkey.api.util.ContextListener;
 import org.labkey.api.util.JspTestCase;
 import org.labkey.api.util.PageFlowUtil;
@@ -89,6 +90,7 @@
 
 import static org.labkey.api.assay.DefaultDataTransformer.LEGACY_SESSION_COOKIE_NAME_REPLACEMENT;
 import static org.labkey.api.assay.DefaultDataTransformer.LEGACY_SESSION_ID_REPLACEMENT;
+import static org.labkey.api.assay.plate.AssayPlateMetadataService.EXPERIMENTAL_APP_PLATE_SUPPORT;
 
 public class AssayModule extends SpringModule
 {
@@ -246,6 +248,9 @@ public void moduleStartupComplete(ServletContext servletContext)
         });
 
         ExperimentService.get().addExperimentListener(new AssayExperimentListener());
+
+        AdminConsole.addExperimentalFeatureFlag(new AdminConsole.ExperimentalFeatureFlag(EXPERIMENTAL_APP_PLATE_SUPPORT,
+                "Plate samples in Biologics", "Plate samples in Biologics for import and analysis.", false, true));
     }
 
     @Override
diff --git a/assay/src/org/labkey/assay/PlateController.java b/assay/src/org/labkey/assay/PlateController.java
index 6ba0652c80b..eb6b935dd36 100644
--- a/assay/src/org/labkey/assay/PlateController.java
+++ b/assay/src/org/labkey/assay/PlateController.java
@@ -30,10 +30,12 @@
 import org.labkey.api.action.SimpleViewAction;
 import org.labkey.api.action.SpringActionController;
 import org.labkey.api.assay.plate.Plate;
+import org.labkey.api.assay.plate.PlateCustomField;
 import org.labkey.api.assay.plate.PlateService;
 import org.labkey.api.assay.security.DesignAssayPermission;
 import org.labkey.api.data.Container;
 import org.labkey.api.data.ContainerManager;
+import org.labkey.api.exp.property.Domain;
 import org.labkey.api.gwt.client.model.GWTPropertyDescriptor;
 import org.labkey.api.gwt.server.BaseRemoteService;
 import org.labkey.api.security.RequiresAnyOf;
@@ -57,9 +59,7 @@
 import org.labkey.assay.plate.PlateDataServiceImpl;
 import org.labkey.assay.plate.PlateManager;
 import org.labkey.assay.plate.PlateUrls;
-import org.labkey.assay.plate.model.PlateCustomField;
 import org.labkey.assay.plate.model.PlateType;
-import org.labkey.assay.plate.model.WellCustomField;
 import org.labkey.assay.view.AssayGWTView;
 import org.springframework.validation.BindException;
 import org.springframework.validation.Errors;
@@ -734,12 +734,12 @@ public Object execute(CustomFieldsForm form, BindException errors) throws Except
     }
 
     @RequiresPermission(ReadPermission.class)
-    public class GetFieldsAction extends MutatingApiAction<CustomFieldsForm>
+    public class GetFieldsAction extends ReadOnlyApiAction<CustomFieldsForm>
     {
         @Override
         public Object execute(CustomFieldsForm form, BindException errors) throws Exception
         {
-            return success(PlateManager.get().getFields(getContainer(), getUser(), form.getPlateId()));
+            return success(PlateManager.get().getFields(getContainer(), form.getPlateId()));
         }
     }
 
@@ -753,39 +753,50 @@ public Object execute(CustomFieldsForm form, BindException errors) throws Except
         }
     }
 
-    public static class SetFieldsForm extends CustomFieldsForm
+    /**
+     * Returns the Domain ID for the plate metadata configured for this container. If the domain has
+     * not been created then null will be returned.
+     */
+    @RequiresPermission(ReadPermission.class)
+    public class GetPlateMetadataDomainAction extends ReadOnlyApiAction<Object>
     {
-        private Integer _wellId;
-        private List<WellCustomField> _wellFields;
-
-        public Integer getWellId()
+        @Override
+        public Object execute(Object form, BindException errors) throws Exception
         {
-            return _wellId;
+            Domain domain = PlateManager.get().getPlateMetadataDomain(getContainer(), getUser());
+            return success(domain != null ? domain.getTypeId() : null);
         }
+    }
 
-        public void setWellId(Integer wellId)
-        {
-            _wellId = wellId;
-        }
+    public static class GetPlateForm
+    {
+        private Integer _rowId;
 
-        public List<WellCustomField> getWellFields()
+        public Integer getRowId()
         {
-            return _wellFields;
+            return _rowId;
         }
 
-        public void setWellFields(List<WellCustomField> wellFields)
+        public void setRowId(Integer rowId)
         {
-            _wellFields = wellFields;
+            _rowId = rowId;
         }
     }
 
-    @RequiresPermission(UpdatePermission.class)
-    public class SetFieldsAction extends MutatingApiAction<SetFieldsForm>
+    @RequiresPermission(ReadPermission.class)
+    public static class GetPlateAction extends ReadOnlyApiAction<GetPlateForm>
     {
         @Override
-        public Object execute(SetFieldsForm form, BindException errors) throws Exception
+        public void validateForm(GetPlateForm form, Errors errors)
+        {
+            if (form.getRowId() == null)
+                errors.reject(ERROR_GENERIC, "Plate \"rowId\" is required.");
+        }
+
+        @Override
+        public Object execute(GetPlateForm form, BindException errors) throws Exception
         {
-            return success(PlateManager.get().setFields(getContainer(), getUser(), form.getPlateId(), form.getWellId(), form.getWellFields()));
+            return PlateManager.get().getPlate(getContainer(), form.getRowId());
         }
     }
 }
diff --git a/assay/src/org/labkey/assay/plate/AssayPlateMetadataServiceImpl.java b/assay/src/org/labkey/assay/plate/AssayPlateMetadataServiceImpl.java
index db4caaddd44..957df93cc75 100644
--- a/assay/src/org/labkey/assay/plate/AssayPlateMetadataServiceImpl.java
+++ b/assay/src/org/labkey/assay/plate/AssayPlateMetadataServiceImpl.java
@@ -44,7 +44,7 @@
 import org.labkey.api.security.User;
 import org.labkey.assay.TSVProtocolSchema;
 import org.labkey.assay.plate.model.Well;
-import org.labkey.assay.plate.model.WellCustomField;
+import org.labkey.api.assay.plate.WellCustomField;
 import org.labkey.assay.query.AssayDbSchema;
 
 import java.io.File;
@@ -283,7 +283,7 @@ public List<Map<String, Object>> mergePlateMetadata(
                 throw new ExperimentException("Unable to resolve the plate for the run");
 
             // if there are metadata fields configured for this plate
-            if (!PlateManager.get().getFields(container, user, plate.getRowId()).isEmpty())
+            if (!PlateManager.get().getFields(container, plate.getRowId()).isEmpty())
             {
                 // create the map of well locations to the well
                 Map<Position, Well> positionToWell = new HashMap<>();
diff --git a/assay/src/org/labkey/assay/plate/PlateCache.java b/assay/src/org/labkey/assay/plate/PlateCache.java
index d96b69464b4..d827a61bae4 100644
--- a/assay/src/org/labkey/assay/plate/PlateCache.java
+++ b/assay/src/org/labkey/assay/plate/PlateCache.java
@@ -4,121 +4,217 @@
 import org.apache.logging.log4j.Logger;
 import org.jetbrains.annotations.NotNull;
 import org.jetbrains.annotations.Nullable;
+import org.json.JSONObject;
 import org.labkey.api.assay.plate.Plate;
 import org.labkey.api.cache.Cache;
+import org.labkey.api.cache.CacheLoader;
 import org.labkey.api.cache.CacheManager;
 import org.labkey.api.data.Container;
+import org.labkey.api.data.ContainerManager;
 import org.labkey.api.data.SimpleFilter;
 import org.labkey.api.data.TableSelector;
 import org.labkey.api.exp.Lsid;
+import org.labkey.api.query.FieldKey;
 import org.labkey.assay.query.AssayDbSchema;
 
 import java.util.ArrayList;
 import java.util.Collection;
+import java.util.Collections;
 import java.util.Comparator;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.stream.Collectors;
 
 public class PlateCache
 {
-    private static final Cache<Container, PlateCollections> PLATE_COLLECTIONS_CACHE = CacheManager.getBlockingCache(CacheManager.UNLIMITED, CacheManager.DAY, "Plate Cache", (c, argument) -> new PlateCollections(c));
+    private static final PlateLoader _loader = new PlateLoader();
+    private static final Cache<String, Plate> PLATE_CACHE = CacheManager.getBlockingStringKeyCache(CacheManager.UNLIMITED, CacheManager.DAY, "Plate Cache", _loader);
     private static final Logger LOG = LogManager.getLogger(PlateCache.class);
 
-    private static class PlateCollections
+    private static class PlateLoader implements CacheLoader<String, Plate>
     {
-        private final Map<Integer, Plate> _rowIdMap;
-        private final Map<String, Plate> _nameMap;
-        private final Map<Lsid, Plate> _lsidMap;
-        private final List<Plate> _templates;
+        private Map<Container, List<Plate>> _containerPlateMap = new HashMap<>();            // internal collection to help un-cache all plates for a container
 
-        private PlateCollections(Container c)
+        @Override
+        public Plate load(@NotNull String key, @Nullable Object argument)
         {
-            Map<Integer, Plate> rowIdMap = new HashMap<>();
-            Map<String, Plate> nameMap = new HashMap<>();
-            Map<Lsid, Plate> lsidMap = new HashMap<>();
-            List<Plate> templates = new ArrayList<>();
+            // parse the cache key
+            PlateCacheKey cacheKey = new PlateCacheKey(key);
 
-            new TableSelector(AssayDbSchema.getInstance().getTableInfoPlate(), SimpleFilter.createContainerFilter(c), null).forEach(PlateImpl.class, plate -> {
-                PlateManager.get().populatePlate(plate);
-
-                rowIdMap.put(plate.getRowId(), plate);
-                if (nameMap.containsKey(plate.getName()))
-                {
-                    LOG.error(String.format("A duplicate Plate name : %s was found in the same folder : %s. We recommend that the duplicate plate(s) are deleted.", plate.getName(), c.getPath()));
-                }
-                nameMap.put(plate.getName(), plate);
-                lsidMap.put(Lsid.parse(plate.getLSID()), plate);
-                if (plate.isTemplate())
-                    templates.add(plate);
-            });
-
-            templates.sort(Comparator.comparing(Plate::getName));
-
-            _templates = templates;
-            _rowIdMap = rowIdMap;
-            _nameMap = nameMap;
-            _lsidMap = lsidMap;
-        }
-
-        private @Nullable Plate getForRowId(int rowId)
-        {
-            return _rowIdMap.get(rowId);
-        }
+            SimpleFilter filter = SimpleFilter.createContainerFilter(cacheKey._container);
+            filter.addCondition(FieldKey.fromParts(cacheKey._type.name()), cacheKey._identifier);
 
-        private @Nullable Plate getForName(String name)
-        {
-            return _nameMap.get(name);
-        }
+            List<PlateImpl> plates = new TableSelector(AssayDbSchema.getInstance().getTableInfoPlate(), filter, null).getArrayList(PlateImpl.class);
+            assert plates.size() <= 1;
 
-        private @Nullable Plate getForLsid(Lsid lsid)
-        {
-            return _lsidMap.get(lsid);
-        }
+            if (plates.size() == 1)
+            {
+                PlateImpl plate = plates.get(0);
+                PlateManager.get().populatePlate(plate);
+                LOG.info(String.format("Caching plate \"%s\" for folder %s", plate.getName(), cacheKey._container.getPath()));
 
-        private @NotNull Collection<Plate> getPlates()
-        {
-            return _rowIdMap.values();
+                // add all cache keys for this plate
+                addCacheKeys(cacheKey, plate);
+                return plate;
+            }
+            return null;
         }
 
-        private @NotNull List<Plate> getPlateTemplates()
+        private void addCacheKeys(PlateCacheKey cacheKey, Plate plate)
         {
-            return _templates;
+            if (plate != null)
+            {
+                if (plate.getName() == null)
+                    throw new IllegalArgumentException("Plate cannot be cached, name is null");
+                if (plate.getRowId() == null)
+                    throw new IllegalArgumentException("Plate cannot be cached, rowId is null");
+                if (plate.getLSID() == null)
+                    throw new IllegalArgumentException("Plate cannot be cached, LSID is null");
+
+                // add the plate for the other key types
+                if (cacheKey._type != PlateCacheKey.Type.rowId)
+                    PLATE_CACHE.put(PlateCacheKey.getCacheKey(plate.getContainer(), plate.getRowId()), plate);
+                if (cacheKey._type != PlateCacheKey.Type.lsid)
+                    PLATE_CACHE.put(PlateCacheKey.getCacheKey(plate.getContainer(), Lsid.parse(plate.getLSID())), plate);
+                if (cacheKey._type != PlateCacheKey.Type.name)
+                    PLATE_CACHE.put(PlateCacheKey.getCacheKey(plate.getContainer(), plate.getName()), plate);
+
+                _containerPlateMap.computeIfAbsent(cacheKey._container, k -> new ArrayList<>()).add(plate);
+            }
         }
     }
 
-    static @Nullable Plate getPlate(Container c, int rowId)
+    public static @Nullable Plate getPlate(Container c, int rowId)
     {
-        return PLATE_COLLECTIONS_CACHE.get(c).getForRowId(rowId);
+        Plate plate = PLATE_CACHE.get(PlateCacheKey.getCacheKey(c, rowId));
+        // We allow plates to be mutated, return a copy of the cached object which still references the
+        // original wells and well groups
+        return plate != null ? plate.copy() : null;
     }
 
-    static @Nullable Plate getPlate(Container c, String name)
+    public static @Nullable Plate getPlate(Container c, String name)
     {
-        return PLATE_COLLECTIONS_CACHE.get(c).getForName(name);
+        Plate plate = PLATE_CACHE.get(PlateCacheKey.getCacheKey(c, name));
+        return plate != null ? plate.copy() : null;
     }
 
-    static @Nullable Plate getPlate(Container c, Lsid lsid)
+    public static @Nullable Plate getPlate(Container c, Lsid lsid)
     {
-        return PLATE_COLLECTIONS_CACHE.get(c).getForLsid(lsid);
+        Plate plate = PLATE_CACHE.get(PlateCacheKey.getCacheKey(c, lsid));
+        return plate != null ? plate.copy() : null;
     }
 
-    static @NotNull Collection<Plate> getPlates(Container c)
+    public static @NotNull Collection<Plate> getPlates(Container c)
     {
-        return PLATE_COLLECTIONS_CACHE.get(c).getPlates();
+        List<Plate> plates = new ArrayList<>();
+        List<Integer> ids = new TableSelector(AssayDbSchema.getInstance().getTableInfoPlate(),
+                Collections.singleton("RowId"),
+                SimpleFilter.createContainerFilter(c), null).getArrayList(Integer.class);
+        for (Integer id : ids)
+        {
+            plates.add(PLATE_CACHE.get(PlateCacheKey.getCacheKey(c, id)));
+        }
+        return plates;
     }
 
-    static @NotNull List<Plate> getPlateTemplates(Container c)
+    public static @NotNull List<Plate> getPlateTemplates(Container c)
     {
-        return PLATE_COLLECTIONS_CACHE.get(c).getPlateTemplates();
+        List<Plate> templates = new ArrayList<>();
+        for (Plate plate : getPlates(c))
+        {
+            if (plate.isTemplate())
+                templates.add(plate);
+        }
+        return templates.stream()
+                .sorted(Comparator.comparing(Plate::getName))
+                .collect(Collectors.toList());
     }
 
     public static void uncache(Container c)
     {
-        PLATE_COLLECTIONS_CACHE.remove(c);
+        LOG.info(String.format("Clearing cache for folder %s", c.getPath()));
+
+        // uncache all plates for this container
+        if (_loader._containerPlateMap.containsKey(c))
+        {
+            List<Plate> plates = new ArrayList<>(_loader._containerPlateMap.get(c));
+            for (Plate plate : plates)
+            {
+                uncache(c, plate);
+            }
+            _loader._containerPlateMap.remove(c);
+        }
+    }
+
+    public static void uncache(Container c, Plate plate)
+    {
+        LOG.info(String.format("Un-caching plate \"%s\" for folder %s", plate.getName(), c.getPath()));
+
+        if (plate.getName() == null)
+            throw new IllegalArgumentException("Plate cannot be uncached, name is null");
+        if (plate.getRowId() == null)
+            throw new IllegalArgumentException("Plate cannot be uncached, rowId is null");
+        if (plate.getLSID() == null)
+            throw new IllegalArgumentException("Plate cannot be uncached, LSID is null");
+
+        PLATE_CACHE.remove(PlateCacheKey.getCacheKey(c, plate.getName()));
+        PLATE_CACHE.remove(PlateCacheKey.getCacheKey(c, plate.getRowId()));
+        PLATE_CACHE.remove(PlateCacheKey.getCacheKey(c, Lsid.parse(plate.getLSID())));
+
+        if (_loader._containerPlateMap.containsKey(c))
+            _loader._containerPlateMap.get(c).remove(plate);
     }
 
     public static void clearCache()
     {
-        PLATE_COLLECTIONS_CACHE.clear();
+        PLATE_CACHE.clear();
+    }
+
+    private static class PlateCacheKey
+    {
+        enum Type
+        {
+            rowId,
+            name,
+            lsid,
+        }
+        private Type _type;
+        private Container _container;
+        private Object _identifier;
+
+        PlateCacheKey(String key)
+        {
+            JSONObject json = new JSONObject(key);
+
+            _type = json.getEnum(Type.class, "type");
+            _container = ContainerManager.getForId(json.getString("container"));
+            _identifier = json.get("identifier");
+        }
+
+        public static String getCacheKey(Container c, String name)
+        {
+            return _getCacheKey(c, Type.name, name);
+        }
+
+        public static String getCacheKey(Container c, Integer plateId)
+        {
+            return _getCacheKey(c, Type.rowId, plateId);
+        }
+
+        public static String getCacheKey(Container c, Lsid lsid)
+        {
+            return _getCacheKey(c, Type.lsid, lsid.toString());
+        }
+
+        private static String _getCacheKey(Container c, Type type, Object identifier)
+        {
+            JSONObject json = new JSONObject();
+            json.put("container", c.getId());
+            json.put("type", type.name());
+            json.put("identifier", identifier);
+
+            return json.toString();
+        }
     }
 }
diff --git a/assay/src/org/labkey/assay/plate/PlateDocumentProvider.java b/assay/src/org/labkey/assay/plate/PlateDocumentProvider.java
index e3cc55513d6..8dfa7381403 100644
--- a/assay/src/org/labkey/assay/plate/PlateDocumentProvider.java
+++ b/assay/src/org/labkey/assay/plate/PlateDocumentProvider.java
@@ -109,7 +109,7 @@ private Lsid fromDocumentId(@NotNull String resourceIdentifier)
             private @Nullable Plate getPlate(@NotNull String resourceIdentifier)
             {
                 Lsid lsid = fromDocumentId(resourceIdentifier);
-                return PlateManager.get().getPlate(lsid.toString());
+                return PlateManager.get().getPlate(lsid);
             }
 
             @Override
diff --git a/assay/src/org/labkey/assay/plate/PlateImpl.java b/assay/src/org/labkey/assay/plate/PlateImpl.java
index b99287a6c65..246c3e2f511 100644
--- a/assay/src/org/labkey/assay/plate/PlateImpl.java
+++ b/assay/src/org/labkey/assay/plate/PlateImpl.java
@@ -21,6 +21,7 @@
 import org.jetbrains.annotations.NotNull;
 import org.jetbrains.annotations.Nullable;
 import org.labkey.api.assay.plate.Plate;
+import org.labkey.api.assay.plate.PlateCustomField;
 import org.labkey.api.assay.plate.PlateService;
 import org.labkey.api.assay.plate.Position;
 import org.labkey.api.assay.plate.PositionImpl;
@@ -30,6 +31,7 @@
 import org.labkey.api.data.Transient;
 import org.labkey.api.query.QueryRowReference;
 import org.labkey.api.util.GUID;
+import org.labkey.api.util.UnexpectedException;
 import org.labkey.api.view.ActionURL;
 import org.labkey.assay.PlateController;
 
@@ -43,7 +45,7 @@
 import java.util.Map;
 
 @JsonInclude(JsonInclude.Include.NON_NULL)
-public class PlateImpl extends PropertySetImpl implements Plate
+public class PlateImpl extends PropertySetImpl implements Plate, Cloneable
 {
     private String _name;
     private Integer _rowId;
@@ -61,10 +63,12 @@ public class PlateImpl extends PropertySetImpl implements Plate
     private List<WellGroupImpl> _deletedGroups;
 
     private WellImpl[][] _wells;
-    private Map<Integer, WellImpl> _wellMap;
+    private Map<Integer, Well> _wellMap;
 
     private int _runId;      // NO_RUNID means no run yet, well data comes from file, dilution data must be calculated
     private int _plateNumber;
+    private List<PlateCustomField> _customFields = Collections.emptyList();
+    private Integer _metadataDomainId;
 
     public PlateImpl()
     {
@@ -523,9 +527,13 @@ public void setWells(WellImpl[][] wells)
     }
 
     @JsonIgnore
-    public WellImpl[][] getWells()
+    @Override
+    public List<Well> getWells()
     {
-        return _wells;
+        if (_wellMap != null)
+            return _wellMap.values().stream().toList();
+        else
+            return Collections.emptyList();
     }
 
     @Override
@@ -556,4 +564,39 @@ public int getPlateNumber()
     {
         return _plateNumber;
     }
+
+    @Override
+    public @NotNull List<PlateCustomField> getCustomFields()
+    {
+        return _customFields;
+    }
+
+    public void setCustomFields(List<PlateCustomField> customFields)
+    {
+        _customFields = customFields;
+    }
+
+    public PlateImpl copy()
+    {
+        try
+        {
+            return (PlateImpl)super.clone();
+        }
+        catch (CloneNotSupportedException e)
+        {
+            throw UnexpectedException.wrap(e);
+        }
+    }
+
+    @Nullable
+    @Override
+    public Integer getMetadataDomainId()
+    {
+        return _metadataDomainId;
+    }
+
+    public void setMetadataDomainId(Integer metadataDomainId)
+    {
+        _metadataDomainId = metadataDomainId;
+    }
 }
diff --git a/assay/src/org/labkey/assay/plate/PlateManager.java b/assay/src/org/labkey/assay/plate/PlateManager.java
index 74bd2a7da24..2f81105ad86 100644
--- a/assay/src/org/labkey/assay/plate/PlateManager.java
+++ b/assay/src/org/labkey/assay/plate/PlateManager.java
@@ -27,17 +27,23 @@
 import org.labkey.api.assay.dilution.DilutionCurve;
 import org.labkey.api.assay.plate.AbstractPlateTypeHandler;
 import org.labkey.api.assay.plate.Plate;
+import org.labkey.api.assay.plate.PlateCustomField;
 import org.labkey.api.assay.plate.PlateService;
 import org.labkey.api.assay.plate.PlateTypeHandler;
 import org.labkey.api.assay.plate.Position;
 import org.labkey.api.assay.plate.PositionImpl;
 import org.labkey.api.assay.plate.Well;
+import org.labkey.api.assay.plate.WellCustomField;
 import org.labkey.api.assay.plate.WellGroup;
 import org.labkey.api.collections.ArrayListMap;
+import org.labkey.api.collections.CaseInsensitiveHashMap;
+import org.labkey.api.data.ColumnInfo;
 import org.labkey.api.data.Container;
+import org.labkey.api.data.ContainerManager;
 import org.labkey.api.data.DbScope;
 import org.labkey.api.data.ImportAliasable;
 import org.labkey.api.data.ObjectFactory;
+import org.labkey.api.data.Results;
 import org.labkey.api.data.SQLFragment;
 import org.labkey.api.data.SimpleFilter;
 import org.labkey.api.data.Sort;
@@ -82,9 +88,7 @@
 import org.labkey.api.view.ActionURL;
 import org.labkey.api.webdav.WebdavResource;
 import org.labkey.assay.TsvAssayProvider;
-import org.labkey.assay.plate.model.PlateCustomField;
 import org.labkey.assay.plate.model.PlateType;
-import org.labkey.assay.plate.model.WellCustomField;
 import org.labkey.assay.plate.model.WellGroupBean;
 import org.labkey.assay.plate.query.PlateSchema;
 import org.labkey.assay.plate.query.PlateTable;
@@ -114,6 +118,7 @@
 import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertNull;
 import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
 
 public class PlateManager implements PlateService
 {
@@ -336,16 +341,17 @@ public Plate createPlateTemplate(Container container, String templateType, int r
         return PlateCache.getPlate(container, lsid);
     }
 
-    /**
-     * Note that this does not use the cache.
-     */
-    public @Nullable Plate getPlate(String lsid)
+    public @Nullable Plate getPlate(Lsid lsid)
     {
         SimpleFilter filter = new SimpleFilter(FieldKey.fromParts("lsid"), lsid);
-        PlateImpl plate = new TableSelector(AssayDbSchema.getInstance().getTableInfoPlate(), filter, null).getObject(PlateImpl.class);
-        populatePlate(plate);
-
-        return plate;
+        String container = new TableSelector(AssayDbSchema.getInstance().getTableInfoPlate(), Collections.singleton("Container"), filter, null).getObject(String.class);
+        if (container != null)
+        {
+            Container c = ContainerManager.getForId(container);
+            if (c != null)
+                return PlateCache.getPlate(c, lsid);
+        }
+        return null;
     }
 
     /**
@@ -353,10 +359,7 @@ public Plate createPlateTemplate(Container container, String templateType, int r
      */
     public boolean plateExists(Container c, String name)
     {
-        SimpleFilter filter = SimpleFilter.createContainerFilter(c);
-        filter.addCondition(FieldKey.fromParts("Name"), name);
-
-        return new TableSelector(AssayDbSchema.getInstance().getTableInfoPlate(), filter, null).getRowCount() > 0;
+        return PlateCache.getPlate(c, name) != null;
     }
 
     private Collection<Plate> getPlates(Container c)
@@ -396,7 +399,6 @@ public WellGroup getWellGroup(String lsid)
         return null;
     }
 
-
     private void setProperties(Container container, PropertySetImpl propertySet)
     {
         Map<String, ObjectProperty> props = OntologyManager.getPropertyObjects(container, propertySet.getLSID());
@@ -476,6 +478,32 @@ protected void populatePlate(PlateImpl plate)
 
         for (WellGroupImpl group : sortedGroups)
             plate.addWellGroup(group);
+
+        // custom plate properties
+        Domain domain = getPlateMetadataDomain(plate.getContainer(), null);
+        if (domain != null)
+        {
+            plate.setMetadataDomainId(domain.getTypeId());
+            SQLFragment sqlPlateProps = new SQLFragment("SELECT PropertyURI FROM ").append(AssayDbSchema.getInstance().getTableInfoPlateProperty(), "PP")
+                    .append(" WHERE PlateId = ?").add(plate.getRowId());
+
+            List<DomainProperty> fields = new ArrayList<>();
+            for (String uri : new SqlSelector(AssayDbSchema.getInstance().getSchema(), sqlPlateProps).getArrayList(String.class))
+            {
+                DomainProperty dp = domain.getPropertyByURI(uri);
+                if (dp == null)
+                    throw new IllegalArgumentException("Failed to get plate custom field. \"" + uri + "\" does not exist on domain.");
+
+                fields.add(dp);
+            }
+
+            if (!fields.isEmpty())
+            {
+                plate.setCustomFields(fields.stream()
+                        .sorted(Comparator.comparing(DomainProperty::getName))
+                        .map(PlateCustomField::new).toList());
+            }
+        }
     }
 
     private WellImpl[] getWells(Plate plate)
@@ -540,6 +568,11 @@ private int savePlateImpl(Container container, User user, PlateImpl plate) throw
                 Lsid lsid = Lsid.parse(plateInstanceLsid);
                 plateObjectLsid = lsid.edit().setObjectId(LSID_CLASS_OBJECT_ID).toString();
 
+                // special case if the plate name changes, we want to remove the cache key with the old name
+                Plate oldPlate = getPlate(container, plateId);
+                if (!oldPlate.getName().equals(plate.getName()))
+                    clearCache(container, oldPlate);
+
                 qus.updateRows(user, container, Collections.singletonList(plateRow), null, errors, null, null);
                 if (errors.hasErrors())
                     throw errors;
@@ -552,6 +585,8 @@ private int savePlateImpl(Container container, User user, PlateImpl plate) throw
                     throw errors;
                 plateId = (Integer)insertedRows.get(0).get("RowId");
                 plateInstanceLsid = (String)insertedRows.get(0).get("Lsid");
+                plate.setRowId(plateId);
+                plate.setLsid(plateInstanceLsid);
             }
             savePropertyBag(container, plateInstanceLsid, plateObjectLsid, plate.getProperties(), updateExisting);
 
@@ -609,9 +644,9 @@ private int savePlateImpl(Container container, User user, PlateImpl plate) throw
             Map<Pair<Integer, Integer>, PositionImpl> existingPositionMap = new HashMap<>();
             if (updateExisting)
             {
-                for (PositionImpl existingPosition : getWells(plate))
+                for (Well existingPosition : plate.getWells())
                 {
-                    existingPositionMap.put(Pair.of(existingPosition.getRow(), existingPosition.getCol()), existingPosition);
+                    existingPositionMap.put(Pair.of(existingPosition.getRow(), existingPosition.getColumn()), (PositionImpl) existingPosition);
                 }
             }
             else
@@ -672,7 +707,7 @@ private int savePlateImpl(Container container, User user, PlateImpl plate) throw
 
             final Integer plateRowId = plateId;
             transaction.addCommitTask(() -> {
-                clearCache(container);
+                clearCache(container, plate);
                 indexPlate(container, plateRowId);
             }, DbScope.CommitTaskOption.POSTCOMMIT);
             transaction.commit();
@@ -753,10 +788,10 @@ public void deletePlate(Container container, User user, int rowid) throws Except
     }
 
     // Called by the Plate Query Update Service after deleting a plate
-    public void afterPlateDelete(Container container, Lsid plateLsid)
+    public void afterPlateDelete(Container container, Plate plate)
     {
-        clearCache(container);
-        deindexPlates(List.of(plateLsid));
+        clearCache(container, plate);
+        deindexPlates(List.of(Lsid.parse(plate.getLSID())));
     }
 
     // Called by the Plate Query Update Service prior to deleting a plate
@@ -764,18 +799,12 @@ public void beforePlateDelete(Container container, Integer plateId)
     {
         final AssayDbSchema schema = AssayDbSchema.getInstance();
 
-        SimpleFilter plateFilter = SimpleFilter.createContainerFilter(container);
-        plateFilter.addCondition(FieldKey.fromParts("RowId"), plateId);
-        PlateImpl plate = new TableSelector(schema.getTableInfoPlate(),
-                plateFilter, null).getObject(PlateImpl.class);
-        WellGroupImpl[] wellgroups = getWellGroups(plate);
-        WellImpl[] wells = getWells(plate);
-
+        Plate plate = PlateCache.getPlate(container, plateId);
         List<String> lsids = new ArrayList<>();
         lsids.add(plate.getLSID());
-        for (WellGroupImpl wellgroup : wellgroups)
+        for (WellGroup wellgroup : plate.getWellGroups())
             lsids.add(wellgroup.getLSID());
-        for (WellImpl well : wells)
+        for (Well well : plate.getWells())
             lsids.add(well.getLsid());
 
         SimpleFilter plateIdFilter = SimpleFilter.createContainerFilter(container);
@@ -957,7 +986,7 @@ public Plate getObject(Lsid lsid)
             if (lsid == null)
                 return null;
 
-            return PlateManager.get().getPlate(lsid.toString());
+            return PlateManager.get().getPlate(lsid);
         }
 
         @Override
@@ -1035,10 +1064,10 @@ public void registerLsidHandlers()
     public Plate copyPlate(Plate source, User user, Container destContainer)
             throws Exception
     {
-        Plate destination = PlateService.get().getPlate(destContainer, source.getName());
+        Plate destination = getPlate(destContainer, source.getName());
         if (destination != null)
             throw new PlateService.NameConflictException(source.getName());
-        destination = PlateService.get().createPlateTemplate(destContainer, source.getType(), source.getRows(), source.getColumns());
+        destination = createPlateTemplate(destContainer, source.getType(), source.getRows(), source.getColumns());
         destination.setName(source.getName());
         for (String property : source.getPropertyNames())
             destination.setProperty(property, source.getProperty(property));
@@ -1052,7 +1081,7 @@ public Plate copyPlate(Plate source, User user, Container destContainer)
                 copyGroup.setProperty(property, originalGroup.getProperty(property));
         }
         save(destContainer, user, destination);
-        return this.getPlate(destContainer, destination.getName());
+        return getPlate(destContainer, destination.getName());
     }
 
     @Override
@@ -1065,6 +1094,11 @@ public void registerPlateTypeHandler(PlateTypeHandler handler)
         _plateTypeHandlers.put(handler.getAssayType(), handler);
     }
 
+    public void clearCache(Container c, Plate plate)
+    {
+        PlateCache.uncache(c, plate);
+    }
+
     public void clearCache(Container c)
     {
         PlateCache.uncache(c);
@@ -1214,7 +1248,8 @@ private Container getPlateMetadataDomainContainer(Container container)
         return container.isProject() ? container : container.getProject();
     }
 
-    private @NotNull Domain ensurePlateMetadataDomain(Container container, User user) throws ValidationException
+    @Override
+    public @NotNull Domain ensurePlateMetadataDomain(Container container, User user) throws ValidationException
     {
         Domain vocabDomain = getPlateMetadataDomain(container, user);
 
@@ -1346,46 +1381,46 @@ private Container getPlateMetadataDomainContainer(Container container)
             DomainProperty dp = domain.getPropertyByURI(field.getPropertyURI());
             if (dp == null)
                 throw new IllegalArgumentException("Failed to add plate custom field. \"" + field.getPropertyURI() + "\" does not exist on domain.");
-
             fieldsToAdd.add(dp);
         }
 
         if (!fieldsToAdd.isEmpty())
         {
-            List<String> propertyURIs = fieldsToAdd.stream().map(DomainProperty::getPropertyURI).collect(Collectors.toList());
-
-            // verify fields aren't already associated with the plate
-            SQLFragment sql = new SQLFragment("SELECT PropertyURI FROM ").append(AssayDbSchema.getInstance().getTableInfoPlateProperty(), "PP")
-                    .append(" WHERE PlateId = ? ").add(plateId)
-                    .append(" AND PropertyURI ").appendInClause(propertyURIs, AssayDbSchema.getInstance().getSchema().getSqlDialect());
-
-            List<String> existingProps = new SqlSelector(AssayDbSchema.getInstance().getSchema(), sql).getArrayList(String.class);
-            if (!existingProps.isEmpty())
+            try (DbScope.Transaction transaction = ExperimentService.get().ensureTransaction())
             {
-                throw new IllegalArgumentException("Failed to add plate custom fields. Custom fields \"" + String.join(",", existingProps) + "\" already are associated with this plate.");
-            }
+                Set<String> existingProps = plate.getCustomFields().stream().map(PlateCustomField::getPropertyURI).collect(Collectors.toSet());
+                for (DomainProperty dp : fieldsToAdd)
+                {
+                    if (existingProps.contains(dp.getPropertyURI()))
+                        throw new IllegalArgumentException(String.format("Failed to add plate custom fields. Custom field \"%s\" already is associated with this plate.", dp.getName()));
+                }
 
-            List<List<?>> insertedValues = new LinkedList<>();
-            for (DomainProperty dp : fieldsToAdd)
-            {
-                insertedValues.add(List.of(plateId,
-                        dp.getPropertyId(),
-                        dp.getPropertyURI()));
+                List<List<?>> insertedValues = new LinkedList<>();
+                for (DomainProperty dp : fieldsToAdd)
+                {
+                    insertedValues.add(List.of(plateId,
+                            dp.getPropertyId(),
+                            dp.getPropertyURI()));
+                }
+                String insertSql = "INSERT INTO " + AssayDbSchema.getInstance().getTableInfoPlateProperty() +
+                        " (plateId, propertyId, propertyURI)" +
+                        " VALUES (?, ?, ?)";
+                Table.batchExecute(AssayDbSchema.getInstance().getSchema(), insertSql, insertedValues);
+
+                transaction.addCommitTask(() -> clearCache(container, plate), DbScope.CommitTaskOption.POSTCOMMIT);
+                transaction.commit();
             }
-            String insertSql = "INSERT INTO " + AssayDbSchema.getInstance().getTableInfoPlateProperty() +
-                    " (plateId, propertyId, propertyURI)" +
-                    " VALUES (?, ?, ?)";
-            Table.batchExecute(AssayDbSchema.getInstance().getSchema(), insertSql, insertedValues);
         }
-        return getFields(container, user, plateId);
+        return getFields(container, plateId);
     }
 
-    public List<PlateCustomField> getFields(Container container, User user, Integer plateId)
+    public List<PlateCustomField> getFields(Container container, Integer plateId)
     {
-        List<PlateCustomField> fields = _getFields(container, user, plateId).stream().map(PlateCustomField::new).toList();
-        return fields.stream()
-                .sorted(Comparator.comparing(PlateCustomField::getName))
-                .collect(Collectors.toList());
+        Plate plate = getPlate(container, plateId);
+        if (plate == null)
+            throw new IllegalArgumentException("Failed to get plate custom fields. Plate id \"" + plateId + "\" not found.");
+
+        return plate.getCustomFields();
     }
 
     /**
@@ -1458,54 +1493,27 @@ public List<PlateCustomField> removeFields(Container container, User user, Integ
 
         if (!fieldsToRemove.isEmpty())
         {
-            List<String> propertyURIs = fieldsToRemove.stream().map(DomainProperty::getPropertyURI).collect(Collectors.toList());
-
-            SQLFragment sql = new SQLFragment("DELETE FROM ").append(AssayDbSchema.getInstance().getTableInfoPlateProperty(), "PP")
-                    .append(" WHERE PlateId = ? ").add(plateId)
-                    .append(" AND PropertyURI ").appendInClause(propertyURIs, AssayDbSchema.getInstance().getSchema().getSqlDialect());
-
-            new SqlExecutor(AssayDbSchema.getInstance().getSchema()).execute(sql);
-        }
-        return getFields(container, user, plateId);
-    }
-
-    public List<WellCustomField> setFields(Container container, User user, Integer plateId, Integer wellId, List<WellCustomField> fields) throws ValidationException
-    {
-        Plate plate = getPlate(container, plateId);
-        if (plate == null)
-            throw new IllegalArgumentException("Failed to set plate custom field values. Plate id \"" + plateId + "\" not found.");
-
-        WellImpl well = (WellImpl) plate.getWell(wellId);
-        if (well == null)
-            throw new IllegalArgumentException("Failed to set plate custom field values. Well id \"" + wellId + "\" not found.");
-
-        Domain domain = getPlateMetadataDomain(container, user);
-        if (domain == null)
-            throw new IllegalArgumentException("Failed to set plate custom field values. Custom fields domain does not exist. Try creating fields first.");
-
-        try (DbScope.Transaction transaction = ExperimentService.get().ensureTransaction())
-        {
-            // verify the fields have been configured for the plate
-            Set<String> configuredProps = new HashSet<>();
-            SQLFragment sql = new SQLFragment("SELECT PropertyURI FROM ").append(AssayDbSchema.getInstance().getTableInfoPlateProperty(), "")
-                    .append(" WHERE PlateId = ?").add(plateId);
-            new SqlSelector(AssayDbSchema.getInstance().getSchema(), sql).forEach(String.class, configuredProps::add);
-
-            for (WellCustomField field : fields)
+            try (DbScope.Transaction transaction = ExperimentService.get().ensureTransaction())
             {
-                if (!configuredProps.contains(field.getPropertyURI()))
-                    throw new IllegalArgumentException("Failed to set plate custom field value. Custom field \"" + field.getPropertyURI() + "\" is not configured for this plate.");
+                List<String> propertyURIs = fieldsToRemove.stream().map(DomainProperty::getPropertyURI).collect(Collectors.toList());
+                Set<String> existingProps = plate.getCustomFields().stream().map(PlateCustomField::getPropertyURI).collect(Collectors.toSet());
+                for (DomainProperty dp : fieldsToRemove)
+                {
+                    if (!existingProps.contains(dp.getPropertyURI()))
+                        throw new IllegalArgumentException(String.format("Failed to remove plate custom fields. Custom field \"%s\" is not currently associated with this plate.", dp.getName()));
+                }
 
-                DomainProperty dp = domain.getPropertyByURI(field.getPropertyURI());
-                if (dp == null)
-                    throw new IllegalArgumentException("Failed to set plate custom field values. Custom field \"" + field.getPropertyURI() + "\" not found.");
+                SQLFragment sql = new SQLFragment("DELETE FROM ").append(AssayDbSchema.getInstance().getTableInfoPlateProperty(), "")
+                        .append(" WHERE PlateId = ? ").add(plateId)
+                        .append(" AND PropertyURI ").appendInClause(propertyURIs, AssayDbSchema.getInstance().getSchema().getSqlDialect());
+
+                new SqlExecutor(AssayDbSchema.getInstance().getSchema()).execute(sql);
 
-                OntologyManager.updateObjectProperty(user, container, dp.getPropertyDescriptor(), well.getLsid(), field.getValue(), null, true);
+                transaction.addCommitTask(() -> clearCache(container, plate), DbScope.CommitTaskOption.POSTCOMMIT);
+                transaction.commit();
             }
-            transaction.commit();
         }
-
-        return getWellCustomFields(container, user, plateId, wellId);
+        return getFields(container, plateId);
     }
 
     public static final class TestCase
@@ -1669,5 +1677,123 @@ public void testCreatePlateTemplates() throws Exception
                 assertTrue("Expected saved plate to have the template field set to true", template.isTemplate());
             }
         }
+
+        @Test
+        public void testCreatePlateMetadata() throws Exception
+        {
+            final Container c = JunitUtil.getTestContainer();
+            final User user = TestContext.get().getUser();
+
+            PlateService.get().deleteAllPlateData(c);
+            Domain domain = PlateManager.get().getPlateMetadataDomain(c ,user);
+            if (domain != null)
+                domain.delete(user);
+
+            Plate plate = PlateService.get().createPlateTemplate(c, TsvPlateTypeHandler.TYPE, 16, 24);
+            plate.setName("new plate with metadata");
+            int plateId = PlateService.get().save(c, user, plate);
+
+            // Assert
+            assertTrue("Expected saved plateId to be returned", plateId != 0);
+
+            // create custom properties
+            List<GWTPropertyDescriptor> customFields = List.of(
+                    new GWTPropertyDescriptor("barcode", "http://www.w3.org/2001/XMLSchema#string"),
+                    new GWTPropertyDescriptor("concentration", "http://www.w3.org/2001/XMLSchema#double"),
+                    new GWTPropertyDescriptor("negativeControl", "http://www.w3.org/2001/XMLSchema#double"));
+
+            List<PlateCustomField> fields = PlateManager.get().createPlateMetadataFields(c, user, customFields);
+
+            // Verify returned sorted by name
+            assertTrue("Expected plate custom fields", fields.size() == 3);
+            assertTrue("Expected barcode custom field", fields.get(0).getName().equals("barcode"));
+            assertTrue("Expected concentration custom field", fields.get(1).getName().equals("concentration"));
+            assertTrue("Expected negativeControl custom field", fields.get(2).getName().equals("negativeControl"));
+
+            // assign custom fields to the plate
+            assertTrue("Expected custom fields to be added to the plate", PlateManager.get().addFields(c, user, plateId, fields).size() == 3);
+
+            // verification when adding custom fields to the plate
+            try
+            {
+                PlateManager.get().addFields(c, user, plateId, fields);
+                fail("Expected a validation error when adding existing fields");
+            }
+            catch (IllegalArgumentException e)
+            {
+                assertTrue("Expected validation exception", e.getMessage().equals("Failed to add plate custom fields. Custom field \"barcode\" already is associated with this plate."));
+            }
+
+            // remove a plate custom field
+            fields = PlateManager.get().removeFields(c, user, plateId, List.of(fields.get(0)));
+            assertTrue("Expected 2 plate custom fields", fields.size() == 2);
+            assertTrue("Expected concentration custom field", fields.get(0).getName().equals("concentration"));
+            assertTrue("Expected negativeControl custom field", fields.get(1).getName().equals("negativeControl"));
+
+            // select wells
+            SimpleFilter filter = SimpleFilter.createContainerFilter(c);
+            filter.addCondition(FieldKey.fromParts("PlateId"), plateId);
+            filter.addCondition(FieldKey.fromParts("Row"), 0);
+            List< org.labkey.assay.plate.model.Well> wells = new TableSelector(AssayDbSchema.getInstance().getTableInfoWell(), filter, new Sort("Col")).getArrayList(org.labkey.assay.plate.model.Well.class);
+
+            assertTrue("Expected 24 wells to be returned", wells.size() == 24);
+
+            // update
+            TableInfo wellTable = QueryService.get().getUserSchema(user, c, PlateSchema.SCHEMA_NAME).getTable(WellTable.NAME);
+            QueryUpdateService qus = wellTable.getUpdateService();
+            BatchValidationException errors = new BatchValidationException();
+
+            // verify metadata update works for Property URI as well as field key
+            org.labkey.assay.plate.model.Well well = wells.get(0);
+            List<Map<String, Object>> rows = List.of(CaseInsensitiveHashMap.of(
+                    "rowid", well.getRowId(),
+                    fields.get(0).getPropertyURI(), 1.25,       // concentration
+                    fields.get(1).getPropertyURI(), 5.25            // negativeControl
+            ));
+
+            qus.updateRows(user, c, rows, null, errors, null, null);
+            if (errors.hasErrors())
+                fail(errors.getMessage());
+
+            well = wells.get(1);
+            rows = List.of(CaseInsensitiveHashMap.of(
+                    "rowid", well.getRowId(),
+                    "properties/concentration", 2.25,
+                    "properties/negativeControl", 6.25
+            ));
+
+            qus.updateRows(user, c, rows, null, errors, null, null);
+            if (errors.hasErrors())
+                fail(errors.getMessage());
+
+            ColumnInfo colConcentration = wellTable.getColumn("properties/concentration");
+            ColumnInfo colNegControl = wellTable.getColumn("properties/negativeControl");
+
+            // verify vocab property updates
+            try (Results r = QueryService.get().select(wellTable, List.of(colConcentration, colNegControl), filter, new Sort("Col")))
+            {
+                int row = 0;
+                while (r.next())
+                {
+                    if (row == 0)
+                    {
+                        assertEquals(1.25, r.getDouble(colConcentration.getFieldKey()), 0);
+                        assertEquals(5.25, r.getDouble(colNegControl.getFieldKey()), 0);
+                    }
+                    else if (row == 1)
+                    {
+                        assertEquals(2.25, r.getDouble(colConcentration.getFieldKey()), 0);
+                        assertEquals(6.25, r.getDouble(colNegControl.getFieldKey()), 0);
+                    }
+                    else
+                    {
+                        // the remainder should be null
+                        assertEquals(0, r.getDouble(colConcentration.getFieldKey()), 0);
+                        assertEquals(0, r.getDouble(colNegControl.getFieldKey()), 0);
+                    }
+                    row++;
+                }
+            }
+        }
     }
 }
diff --git a/assay/src/org/labkey/assay/plate/WellGroupImpl.java b/assay/src/org/labkey/assay/plate/WellGroupImpl.java
index 0a48dd6656f..d4b195cf4a3 100644
--- a/assay/src/org/labkey/assay/plate/WellGroupImpl.java
+++ b/assay/src/org/labkey/assay/plate/WellGroupImpl.java
@@ -97,7 +97,6 @@ public WellGroupImpl(PlateImpl plate, WellGroupImpl template)
         return template.detailsURL();
     }
 
-
     private static List<? extends Position> sortPositions(List<? extends Position> positions)
     {
         List<? extends Position> sortedPositions = new ArrayList<>(positions);
@@ -312,6 +311,10 @@ public double getMean()
     @Override
     public Plate getPlate()
     {
+        if (_plate == null && _plateId != null)
+        {
+            _plate = (PlateImpl) PlateCache.getPlate(getContainer(), _plateId);
+        }
         return _plate;
     }
 
diff --git a/assay/src/org/labkey/assay/plate/query/PlateTable.java b/assay/src/org/labkey/assay/plate/query/PlateTable.java
index 59c59cadefd..96bba362da0 100644
--- a/assay/src/org/labkey/assay/plate/query/PlateTable.java
+++ b/assay/src/org/labkey/assay/plate/query/PlateTable.java
@@ -223,7 +223,6 @@ public DataIteratorBuilder createImportDIB(User user, Container container, DataI
         public List<Map<String, Object>> insertRows(User user, Container container, List<Map<String, Object>> rows, BatchValidationException errors, @Nullable Map<Enum, Object> configParameters, Map<String, Object> extraScriptContext)
         {
             List<Map<String, Object>> results = super._insertRowsUsingDIB(user, container, rows, getDataIteratorContext(errors, InsertOption.INSERT, configParameters), extraScriptContext);
-            PlateManager.get().clearCache(container);
             return results;
         }
 
@@ -249,7 +248,7 @@ protected Map<String, Object> updateRow(User user, Container container, Map<Stri
             }
 
             Map<String, Object> newRow = super.updateRow(user, container, row, oldRow);
-            PlateManager.get().clearCache(container);
+            PlateManager.get().clearCache(container, plate);
             return newRow;
         }
 
@@ -267,11 +266,10 @@ protected Map<String, Object> deleteRow(User user, Container container, Map<Stri
 
             try (DbScope.Transaction transaction = AssayDbSchema.getInstance().getScope().ensureTransaction())
             {
-                final Lsid plateLsid = new Lsid(plate.getLSID());
                 PlateManager.get().beforePlateDelete(container, plateId);
                 Map<String, Object> returnMap = super.deleteRow(user, container, oldRowMap);
 
-                transaction.addCommitTask(() -> PlateManager.get().afterPlateDelete(container, plateLsid), DbScope.CommitTaskOption.POSTCOMMIT);
+                transaction.addCommitTask(() -> PlateManager.get().afterPlateDelete(container, plate), DbScope.CommitTaskOption.POSTCOMMIT);
                 transaction.commit();
 
                 return returnMap;
diff --git a/assay/src/org/labkey/assay/plate/query/WellGroupTable.java b/assay/src/org/labkey/assay/plate/query/WellGroupTable.java
index ad7cc853035..478865efd8c 100644
--- a/assay/src/org/labkey/assay/plate/query/WellGroupTable.java
+++ b/assay/src/org/labkey/assay/plate/query/WellGroupTable.java
@@ -17,6 +17,7 @@
 package org.labkey.assay.plate.query;
 
 import org.jetbrains.annotations.Nullable;
+import org.labkey.api.assay.plate.Plate;
 import org.labkey.api.assay.plate.WellGroup;
 import org.labkey.api.collections.CaseInsensitiveHashSet;
 import org.labkey.api.data.ColumnInfo;
@@ -50,6 +51,7 @@
 import org.labkey.api.query.SimpleUserSchema;
 import org.labkey.api.query.UserSchema;
 import org.labkey.api.security.User;
+import org.labkey.assay.plate.PlateCache;
 import org.labkey.assay.plate.PlateManager;
 import org.labkey.assay.query.AssayDbSchema;
 
@@ -181,11 +183,19 @@ protected Map<String, Object> deleteRow(User user, Container container, Map<Stri
                 Integer wellGroupId = (Integer)oldRowMap.get("RowId");
                 if (wellGroupId != null)
                 {
-                    PlateManager.get().beforeDeleteWellGroup(container, wellGroupId);
-                    Map<String, Object> returnMap = super.deleteRow(user, container, oldRowMap);
-                    transaction.commit();
-
-                    return returnMap;
+                    WellGroup wellGroup = PlateManager.get().getWellGroup(container, wellGroupId);
+                    if (wellGroup != null)
+                    {
+                        Plate plate = wellGroup.getPlate();
+                        PlateManager.get().beforeDeleteWellGroup(container, wellGroupId);
+                        Map<String, Object> returnMap = super.deleteRow(user, container, oldRowMap);
+
+                        if (plate != null)
+                            transaction.addCommitTask(() -> PlateCache.uncache(container, plate), DbScope.CommitTaskOption.POSTCOMMIT);
+                        transaction.commit();
+
+                        return returnMap;
+                    }
                 }
                 return Collections.emptyMap();
             }
diff --git a/assay/src/org/labkey/assay/plate/query/WellTable.java b/assay/src/org/labkey/assay/plate/query/WellTable.java
index 9d07383d654..541adaa4699 100644
--- a/assay/src/org/labkey/assay/plate/query/WellTable.java
+++ b/assay/src/org/labkey/assay/plate/query/WellTable.java
@@ -2,6 +2,7 @@
 
 import org.jetbrains.annotations.NotNull;
 import org.jetbrains.annotations.Nullable;
+import org.labkey.api.assay.plate.Plate;
 import org.labkey.api.assay.plate.Well;
 import org.labkey.api.collections.CaseInsensitiveHashSet;
 import org.labkey.api.data.ColumnInfo;
@@ -17,14 +18,20 @@
 import org.labkey.api.dataiterator.StandardDataIteratorBuilder;
 import org.labkey.api.dataiterator.TableInsertDataIteratorBuilder;
 import org.labkey.api.exp.Lsid;
+import org.labkey.api.exp.OntologyManager;
+import org.labkey.api.exp.PropertyColumn;
+import org.labkey.api.exp.PropertyDescriptor;
 import org.labkey.api.exp.property.Domain;
+import org.labkey.api.exp.property.DomainProperty;
 import org.labkey.api.exp.property.PropertyService;
 import org.labkey.api.query.BatchValidationException;
 import org.labkey.api.query.DefaultQueryUpdateService;
 import org.labkey.api.query.FieldKey;
+import org.labkey.api.query.InvalidKeyException;
 import org.labkey.api.query.PropertyForeignKey;
 import org.labkey.api.query.QueryForeignKey;
 import org.labkey.api.query.QueryUpdateService;
+import org.labkey.api.query.QueryUpdateServiceException;
 import org.labkey.api.query.SimpleUserSchema;
 import org.labkey.api.query.UserSchema;
 import org.labkey.api.query.ValidationException;
@@ -32,7 +39,9 @@
 import org.labkey.assay.plate.PlateManager;
 import org.labkey.assay.query.AssayDbSchema;
 
+import java.sql.SQLException;
 import java.util.ArrayList;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -45,6 +54,7 @@ public class WellTable extends SimpleUserSchema.SimpleTable<UserSchema>
     public static final String NAME = "Well";
     private static final List<FieldKey> defaultVisibleColumns = new ArrayList<>();
     private static final Set<String> ignoredColumns = new CaseInsensitiveHashSet();
+    private Map<FieldKey, DomainProperty> _vocabularyFieldMap = new HashMap<>();
 
     static
     {
@@ -83,6 +93,13 @@ private void addVocabularyDomains()
                 col.setLabel("Plate Metadata");
                 col.setDescription("Custom properties associated with the plate well");
             }
+
+            for (DomainProperty field : domain.getProperties())
+            {
+                // resolve vocabulary fields by property URI and field key
+                _vocabularyFieldMap.put(FieldKey.fromParts("properties", field.getName()), field);
+                _vocabularyFieldMap.put(FieldKey.fromParts(field.getPropertyURI()), field);
+            }
         }
     }
 
@@ -107,6 +124,38 @@ protected boolean acceptColumn(ColumnInfo col)
         return super.acceptColumn(col) && !ignoredColumns.contains(col.getName());
     }
 
+    /**
+     * Override to resolve Property URIs for vocabulary columns during update. Consider adding the
+     * capability to resolve vocabulary columns by field key or name.
+     */
+    @Override
+    protected ColumnInfo resolveColumn(String name)
+    {
+        ColumnInfo lsidCol = getColumn("LSID", false);
+        if (lsidCol != null)
+        {
+            // Attempt to resolve the column name as a property URI if it looks like a URI
+            FieldKey fieldKey = FieldKey.decode(name);
+            if (_vocabularyFieldMap.containsKey(fieldKey))
+            {
+                DomainProperty field = _vocabularyFieldMap.get(fieldKey);
+
+                // mark vocab propURI col as Voc column
+                PropertyDescriptor pd = OntologyManager.getPropertyDescriptor(field.getPropertyURI(), getContainer());
+                if (pd != null)
+                {
+                    PropertyColumn pc = new PropertyColumn(pd, lsidCol, getContainer(), getUserSchema().getUser(), false);
+                    String label = pc.getLabel();
+                    pc.setFieldKey(fieldKey);
+                    pc.setLabel(label);
+
+                    return pc;
+                }
+            }
+        }
+        return super.resolveColumn(name);
+    }
+
     @Override
     public MutableColumnInfo wrapColumn(ColumnInfo col)
     {
@@ -203,5 +252,22 @@ public List<Map<String, Object>> insertRows(User user, Container container, List
         {
             return super._insertRowsUsingDIB(user, container, rows, getDataIteratorContext(errors, InsertOption.INSERT, configParameters), extraScriptContext);
         }
+
+        @Override
+        protected Map<String, Object> updateRow(User user, Container container, Map<String, Object> row, @NotNull Map<String, Object> oldRow) throws InvalidKeyException, ValidationException, QueryUpdateServiceException, SQLException
+        {
+            // enforce no updates if the plate has been imported in an assay run
+            if (oldRow.containsKey("plateId"))
+            {
+                Plate plate = PlateManager.get().getPlate(container, (Integer)oldRow.get("plateId"));
+                if (plate != null)
+                {
+                    int runsInUse = PlateManager.get().getRunCountUsingPlate(container, plate);
+                    if (runsInUse > 0)
+                        throw new QueryUpdateServiceException(String.format("This %s is used by %d runs and its wells cannot be modified.", plate.isTemplate() ? "Plate template" : "Plate", runsInUse));
+                }
+            }
+            return super.updateRow(user, container, row, oldRow);
+        }
     }
 }