MessageFormat.format() with named parameters
Performing string substitutions using multiple passes is almost always the wrong approach, and leads to bugs.  If one of the values happens to be a string that looks like a %(key), then all sorts of unpredictable things could happen, including various uncontrolled format string attacks!

Therefore, the string replacements must be done in a single pass of the format string.  I recommend doing it using a regular expression.

Furthermore, your method provides no escape mechanism, in case you need to specify a literal %(blah) in the format string.  In Java, it would be customary to use backslash as an escape character.

Suggested solution

This solution uses Matcher.replaceAll(Function&lt;MatchResult,String&gt; replacer), which was introduced in Java 9, to provide each substitution text via a callback.

import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class NamedFormatter {
    private static final Pattern RE = Pattern.compile(
        "\\\\(.)" +         // Treat any character after a backslash literally 
        "|" +
        "(%\\(([^)]+)\\))"  // Look for %(keys) to replace
    );

    private NamedFormatter() {}

    /**
     * Expands format strings containing &lt;code&gt;%(keys)&lt;/code&gt;.
     *
     * &lt;p&gt;Examples:&lt;/p&gt;
     *
     * &lt;ul&gt;
     * &lt;li&gt;&lt;code&gt;NamedFormatter.format("Hello, %(name)!", Map.of("name", "200_success"))&lt;/code&gt; → &lt;code&gt;"Hello, 200_success!"&lt;/code&gt;&lt;/li&gt;
     * &lt;li&gt;&lt;code&gt;NamedFormatter.format("Hello, \%(name)!", Map.of("name", "200_success"))&lt;/code&gt; → &lt;code&gt;"Hello, %(name)!"&lt;/code&gt;&lt;/li&gt;
     * &lt;li&gt;&lt;code&gt;NamedFormatter.format("Hello, %(name)!", Map.of("foo", "bar"))&lt;/code&gt; → &lt;code&gt;"Hello, %(name)!"&lt;/code&gt;&lt;/li&gt;
     * &lt;/ul&gt;
     *
     * @param fmt The format string.  Any character in the format string that
     *            follows a backslash is treated literally.  Any
     *            &lt;code&gt;%(key)&lt;/code&gt; is replaced by its corresponding value
     *            in the &lt;code&gt;values&lt;/code&gt; map.  If the key does not exist
     *            in the &lt;code&gt;values&lt;/code&gt; map, then it is left unsubstituted.
     *
     * @param values Key-value pairs to be used in the substitutions.
     *
     * @return The formatted string.
     */
    public static String format(String fmt, Map&lt;String, Object&gt; values) {
        return RE.matcher(fmt).replaceAll(match -&gt;
            match.group(1) != null ?
                match.group(1) :
                values.getOrDefault(match.group(3), match.group(2)).toString()
        );
    }
}