diff --git a/core/src/main/java/google/registry/model/GetterDelegate.java b/core/src/main/java/google/registry/model/GetterDelegate.java
new file mode 100644
index 000000000..ce34da279
--- /dev/null
+++ b/core/src/main/java/google/registry/model/GetterDelegate.java
@@ -0,0 +1,49 @@
+// Copyright 2025 The Nomulus Authors. All Rights Reserved.
+//
+// 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 google.registry.model;
+
+import static java.lang.annotation.ElementType.FIELD;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+/**
+ * A delegate getter method to be used when getting the value of an {@link ImmutableObject} field.
+ *
+ *
This is useful because Hibernate has limitations on what kinds of types can be used to
+ * represent a field value, the most relevant being that it must be mutable. Since we use Guava's
+ * ImmutableCollections widely, this means that a frequent pattern is to e.g. have a field be
+ * declared as a Set (with a HashSet implementation), but then implement a getter method for that
+ * field that returns the desired ImmutableSet or ImmutableSortedSet. For purposes where it matters
+ * that the field be represented using the appropriate type, such as for outputting in sorted order
+ * via toString, then declare a getter delegate as follows:
+ *
+ *
{@code
+ * @GetterDelegate(methodName = "getAllowedTlds")
+ * Set allowedTlds;
+ *
+ * public ImmutableSortedSet getAllowedTlds() {
+ * return nullToEmptyImmutableSortedCopy(allowedTlds);
+ * }
+ * }
+ */
+@Documented
+@Retention(RUNTIME)
+@Target(FIELD)
+public @interface GetterDelegate {
+ String methodName();
+}
diff --git a/core/src/main/java/google/registry/model/ModelUtils.java b/core/src/main/java/google/registry/model/ModelUtils.java
index e8f19431f..cc9dde20b 100644
--- a/core/src/main/java/google/registry/model/ModelUtils.java
+++ b/core/src/main/java/google/registry/model/ModelUtils.java
@@ -76,11 +76,20 @@ public class ModelUtils {
return ALL_FIELDS_CACHE.get(clazz);
}
- /** Retrieves a field value via reflection. */
+ /**
+ * Retrieves a field value via reflection, using the field's {@link GetterDelegate} if present.
+ */
static Object getFieldValue(Object instance, Field field) {
try {
- return field.get(instance);
- } catch (IllegalAccessException e) {
+ if (field.isAnnotationPresent(GetterDelegate.class)) {
+ return instance
+ .getClass()
+ .getMethod(field.getAnnotation(GetterDelegate.class).methodName())
+ .invoke(instance);
+ } else {
+ return field.get(instance);
+ }
+ } catch (Exception e) {
throw new IllegalStateException(e);
}
}
diff --git a/core/src/main/java/google/registry/model/registrar/Registrar.java b/core/src/main/java/google/registry/model/registrar/Registrar.java
index 37fdd80b8..132d5a5e8 100644
--- a/core/src/main/java/google/registry/model/registrar/Registrar.java
+++ b/core/src/main/java/google/registry/model/registrar/Registrar.java
@@ -54,6 +54,7 @@ import com.google.gson.annotations.Expose;
import com.google.re2j.Pattern;
import google.registry.model.Buildable;
import google.registry.model.CreateAutoTimestamp;
+import google.registry.model.GetterDelegate;
import google.registry.model.JsonMapBuilder;
import google.registry.model.Jsonifiable;
import google.registry.model.UpdateAutoTimestamp;
@@ -245,12 +246,15 @@ public class Registrar extends UpdateAutoTimestampEntity implements Buildable, J
State state;
/** The set of TLDs which this registrar is allowed to access. */
- @Expose Set allowedTlds;
+ @GetterDelegate(methodName = "getAllowedTlds")
+ @Expose
+ Set allowedTlds;
/** Host name of WHOIS server. */
@Expose String whoisServer;
/** Base URLs for the registrar's RDAP servers. */
+ @GetterDelegate(methodName = "getRdapBaseUrls")
Set rdapBaseUrls;
/**
diff --git a/core/src/main/java/google/registry/model/registrar/RegistrarPoc.java b/core/src/main/java/google/registry/model/registrar/RegistrarPoc.java
index 5f9bedf17..6203c31c2 100644
--- a/core/src/main/java/google/registry/model/registrar/RegistrarPoc.java
+++ b/core/src/main/java/google/registry/model/registrar/RegistrarPoc.java
@@ -27,6 +27,7 @@ import com.google.common.collect.ImmutableSet;
import com.google.common.collect.ImmutableSortedSet;
import com.google.gson.annotations.Expose;
import google.registry.model.Buildable;
+import google.registry.model.GetterDelegate;
import google.registry.model.ImmutableObject;
import google.registry.model.JsonMapBuilder;
import google.registry.model.Jsonifiable;
@@ -110,6 +111,7 @@ public class RegistrarPoc extends ImmutableObject implements Jsonifiable, Unsafe
* data is internal to the registry.
*/
@Enumerated(EnumType.STRING)
+ @GetterDelegate(methodName = "getTypes")
@Expose
Set types;
diff --git a/core/src/test/java/google/registry/model/registrar/RegistrarTest.java b/core/src/test/java/google/registry/model/registrar/RegistrarTest.java
index 326dee354..1a0fd51f9 100644
--- a/core/src/test/java/google/registry/model/registrar/RegistrarTest.java
+++ b/core/src/test/java/google/registry/model/registrar/RegistrarTest.java
@@ -23,6 +23,7 @@ import static google.registry.testing.CertificateSamples.SAMPLE_CERT2_HASH;
import static google.registry.testing.CertificateSamples.SAMPLE_CERT_HASH;
import static google.registry.testing.DatabaseHelper.cloneAndSetAutoTimestamps;
import static google.registry.testing.DatabaseHelper.createTld;
+import static google.registry.testing.DatabaseHelper.createTlds;
import static google.registry.testing.DatabaseHelper.newTld;
import static google.registry.testing.DatabaseHelper.persistResource;
import static google.registry.testing.DatabaseHelper.persistResources;
@@ -760,4 +761,16 @@ class RegistrarTest extends EntityTestCase {
.setAllowedTlds(ImmutableSet.of("tld", "xn--q9jyb4c"))
.build();
}
+
+ @Test
+ void testToString_sortsAllowedTlds() {
+ createTlds("foo", "bar", "baz", "gon", "tri");
+ persistResource(
+ registrar
+ .asBuilder()
+ .setAllowedTlds(ImmutableSet.of("gon", "bar", "foo", "tri", "baz"))
+ .build());
+ assertThat(Registrar.loadByRegistrarId("registrar").toString())
+ .contains("allowedTlds=[bar, baz, foo, gon, tri]");
+ }
}
diff --git a/core/src/test/java/google/registry/persistence/EntitiesTest.java b/core/src/test/java/google/registry/persistence/EntitiesTest.java
new file mode 100644
index 000000000..ca060ce5f
--- /dev/null
+++ b/core/src/test/java/google/registry/persistence/EntitiesTest.java
@@ -0,0 +1,52 @@
+// Copyright 2025 The Nomulus Authors. All Rights Reserved.
+//
+// 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 google.registry.persistence;
+
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+
+import com.google.common.collect.ImmutableSet;
+import google.registry.model.GetterDelegate;
+import jakarta.persistence.Entity;
+import java.lang.reflect.Field;
+import org.junit.jupiter.api.Test;
+
+/** Unit tests for Hibernate entities. */
+public class EntitiesTest {
+
+ @Test
+ void getterDelegates_allMethodNamesExist() throws Exception {
+ ImmutableSet> entityClasses =
+ PersistenceXmlUtility.getManagedClasses().stream()
+ .filter(clazz -> clazz.isAnnotationPresent(Entity.class))
+ .collect(toImmutableSet());
+ for (Class> clazz : entityClasses) {
+ for (Field field : clazz.getDeclaredFields()) {
+ if (field.isAnnotationPresent(GetterDelegate.class)) {
+ String methodName = field.getAnnotation(GetterDelegate.class).methodName();
+ // Note that calling getDeclaredMethod(methodName) specifically looks for a method with
+ // no parameters; if there were a method that took e.g. one parameter, you'd need
+ // getDeclaredMethod(methodName, param1) to find it.
+ assertDoesNotThrow(
+ () -> clazz.getDeclaredMethod(methodName),
+ String.format(
+ "Method %s() specified in the GetterDelegate "
+ + "annotation of field %s could not be found on class %s",
+ methodName, field.getName(), clazz.getCanonicalName()));
+ }
+ }
+ }
+ }
+}
diff --git a/core/src/test/java/google/registry/ui/server/console/ConsoleUpdateRegistrarActionTest.java b/core/src/test/java/google/registry/ui/server/console/ConsoleUpdateRegistrarActionTest.java
index dd3eff5b7..7f8d15e1f 100644
--- a/core/src/test/java/google/registry/ui/server/console/ConsoleUpdateRegistrarActionTest.java
+++ b/core/src/test/java/google/registry/ui/server/console/ConsoleUpdateRegistrarActionTest.java
@@ -203,7 +203,9 @@ class ConsoleUpdateRegistrarActionTest extends ConsoleActionBaseTestCase {
"The following changes were made in registry unittest environment to the"
+ " registrar TheRegistrar by admin fte@email.tld:\n"
+ "\n"
- + "allowedTlds: null -> [app, dev]\n"
+ + "allowedTlds:\n"
+ + " ADDED: [app, dev]\n"
+ + " FINAL CONTENTS: [app, dev]\n"
+ "lastPocVerificationDate: 1970-01-01T00:00:00.000Z ->"
+ " 2023-12-12T00:00:00.000Z\n")
.setRecipients(ImmutableList.of(new InternetAddress("notification@test.example")))