All posts

I moved my registry into Custom Metadata, then moved it back

Alwin van Dijken · 12 October 2026 · 4 min read

On the same day, I made an architecture decision and reversed it. The second decision was the better one, and the reasons are worth sharing.

The setting: a form engine in a managed package. Each field type (text, number, date, multi-select and so on, 19 in total) needs a small mapper that turns the raw answer into a typed value. Something has to decide which mapper handles which field type: a registry.

Version 1: a map in code

The first version was the obvious one. One class held a Map from field type to mapper. Adding a field type meant adding one line.

That line bothered me. Adding a field type meant editing an existing class, which breaks the open/closed principle: open for extension, closed for modification.

Version 2: Custom Metadata

So I moved the registry into a Custom Metadata type. One record per field type, naming the Apex class to use. The dispatcher read the records, loaded each mapper with Type.forName and cached it. Adding a field type became “add a record”. Textbook open/closed, and a nice extension point if customers ever want their own field types.

What went wrong

Building on it surfaced three problems quickly.

  • The mappers are code anyway. Every mapper is an Apex class in the same package. Metadata only held the wiring, so one decision (“this field type maps this way”) was now split across a class and a record that had to agree.
  • I traded compile-time checks for reflection. A typo in a record’s class name or field name only showed up when someone used that field type. The compiler used to catch it.
  • The records didn’t deploy. Deploying the Custom Metadata records to my namespaced dev org failed with a generic UNKNOWN_EXCEPTION. I needed an Apex workaround just to run my own dev loop.

Version 3: back to the map

The registry went back to a Map literal. Mappers are constructed directly, and the target field is passed as an SObjectField token, so it stays namespace-safe and the compiler checks every entry. A test asserts that every field type in the picklist has a mapper, so a missing line fails the build.

Adding a field type is one compile-checked line in the same package as the mapper class you already had to write. That’s not a meaningful violation of open/closed. It’s just where the code lives.

The lesson. Open/closed is about protecting stable logic from change, not about moving every list into configuration. If the things being registered are code, keep the registry in code. Build the extension point when a real consumer needs it.

Takeaways

  • Custom Metadata is great for things admins change. It’s a poor home for wiring between classes you ship.
  • Every Type.forName moves an error from compile time to runtime. Make sure you’re getting something for it.
  • Reversing a decision quickly is cheap. Write down why, so the next person doesn’t redo it.