INTERMEDIATEJAVAUTILITIES

Environment Utility for Java

Thread-safe Java helper for reading environment variables, system properties, and profile-aware config with typed accessors and sanitised snapshots.

By Tested on Java 21, Spring Boot 3.4
Published Oct 16, 2025Updated May 25, 2026

EnvironmentUtil is a single static class that unifies how a Java app reads environment variables, system properties, and profile-aware overrides. It returns typed values (int, long, Duration, boolean), caches lookups in a ConcurrentHashMap, and produces a sanitised snapshot so you can log configuration without leaking secrets.

Tested on Java 21, Spring Boot 3.4.

When to Use This

  • Plain Java apps or libraries without a DI container
  • Static initializers that run before Spring is up (AppConfig.PORT = EnvironmentUtil.getInt(...))
  • Tools and CLIs where you want one consistent typed parser instead of Integer.parseInt(System.getenv(...)) scattered everywhere
  • Logging a sanitised view of the running environment for diagnostics

Don't use this when your app already runs inside Spring Boot and only needs string lookups. Use @Value or Environment#getProperty directly. This util is for the cross-cutting case where you need typed reads outside DI scope, or want a single source for isDev / isProd / isTest branching.

Code

import java.time.Duration;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
 
public final class EnvironmentUtil {
 
    private static final ConcurrentMap<String, Optional<String>> CACHE = new ConcurrentHashMap<>();
    private static final Set<String> SENSITIVE = Set.of(
        "PASSWORD", "SECRET", "TOKEN", "KEY", "CREDENTIAL", "PRIVATE"
    );
    private static final String[] PROFILE_KEYS = {
        "SPRING_PROFILES_ACTIVE", "APP_ENV", "ENV", "NODE_ENV"
    };
 
    private EnvironmentUtil() {}
 
    public static String get(String key) {
        return resolve(key).orElse(null);
    }
 
    public static String get(String key, String defaultValue) {
        return resolve(key).orElse(defaultValue);
    }
 
    public static Optional<String> getOptional(String key) {
        return resolve(key);
    }
 
    public static String get(String key, String profile, String defaultValue) {
        Objects.requireNonNull(profile, "profile");
        String candidate = profileAwareKey(key, profile);
        return resolve(candidate).orElseGet(() -> get(key, defaultValue));
    }
 
    public static String require(String key) {
        return resolve(key).orElseThrow(() ->
            new IllegalStateException("Missing mandatory configuration: " + key));
    }
 
    public static int getInt(String key, int defaultValue) {
        String value = get(key);
        if (value == null || value.isBlank()) return defaultValue;
        try {
            return Integer.parseInt(value.trim());
        } catch (NumberFormatException ex) {
            throw new IllegalArgumentException("Invalid int for " + key + ": " + value, ex);
        }
    }
 
    public static long getLong(String key, long defaultValue) {
        String value = get(key);
        if (value == null || value.isBlank()) return defaultValue;
        try {
            return Long.parseLong(value.trim());
        } catch (NumberFormatException ex) {
            throw new IllegalArgumentException("Invalid long for " + key + ": " + value, ex);
        }
    }
 
    public static boolean getBoolean(String key, boolean defaultValue) {
        String value = get(key);
        if (value == null || value.isBlank()) return defaultValue;
        String n = value.trim().toLowerCase(Locale.ROOT);
        if ("true".equals(n) || "1".equals(n) || "yes".equals(n)) return true;
        if ("false".equals(n) || "0".equals(n) || "no".equals(n)) return false;
        throw new IllegalArgumentException("Invalid boolean for " + key + ": " + value);
    }
 
    public static Duration getDuration(String key, Duration defaultValue) {
        String value = get(key);
        if (value == null || value.isBlank()) return defaultValue;
        return parseDuration(value);
    }
 
    public static String getActiveEnvironment() {
        for (String key : PROFILE_KEYS) {
            String value = get(key);
            if (value != null && !value.isBlank()) return value.trim();
        }
        return "default";
    }
 
    public static boolean isDev()  { return isEnv("dev", "development", "local"); }
    public static boolean isProd() { return isEnv("prod", "production"); }
    public static boolean isTest() { return isEnv("test", "qa"); }
 
    public static boolean isEnv(String... candidates) {
        String active = getActiveEnvironment();
        if (active == null) return false;
        for (String c : candidates) if (c != null && active.equalsIgnoreCase(c)) return true;
        return false;
    }
 
    public static Map<String, String> snapshotSanitised() {
        Map<String, String> snapshot = new LinkedHashMap<>();
        System.getenv().forEach(snapshot::putIfAbsent);
        System.getProperties().forEach((k, v) ->
            snapshot.put(String.valueOf(k), String.valueOf(v)));
 
        Map<String, String> redacted = new LinkedHashMap<>();
        snapshot.forEach((k, v) -> redacted.put(k, isSensitiveKey(k) ? "***" : v));
        return Collections.unmodifiableMap(redacted);
    }
 
    public static void clearCache() {
        CACHE.clear();
    }
 
    private static Optional<String> resolve(String key) {
        Objects.requireNonNull(key, "key");
        String trimmed = key.trim();
        if (trimmed.isEmpty()) throw new IllegalArgumentException("Key must not be blank.");
        return CACHE.computeIfAbsent(trimmed, EnvironmentUtil::loadValue);
    }
 
    private static Optional<String> loadValue(String key) {
        String env = System.getenv(key);
        if (env != null && !env.isBlank()) return Optional.of(env);
        String property = System.getProperty(key);
        if (property != null && !property.isBlank()) return Optional.of(property);
        return Optional.empty();
    }
 
    private static Duration parseDuration(String raw) {
        String value = raw.trim().toLowerCase(Locale.ROOT);
        try {
            if (value.endsWith("ms")) return Duration.ofMillis(Long.parseLong(value.substring(0, value.length() - 2).trim()));
            if (value.endsWith("s"))  return Duration.ofSeconds(Long.parseLong(value.substring(0, value.length() - 1).trim()));
            if (value.endsWith("m"))  return Duration.ofMinutes(Long.parseLong(value.substring(0, value.length() - 1).trim()));
            if (value.endsWith("h"))  return Duration.ofHours(Long.parseLong(value.substring(0, value.length() - 1).trim()));
            if (value.endsWith("d"))  return Duration.ofDays(Long.parseLong(value.substring(0, value.length() - 1).trim()));
            if (value.matches("\\d+")) return Duration.ofSeconds(Long.parseLong(value));
            return Duration.parse(raw);
        } catch (Exception ex) {
            throw new IllegalArgumentException("Invalid duration: " + raw, ex);
        }
    }
 
    private static String profileAwareKey(String key, String profile) {
        return key + "_" + profile.trim().toUpperCase(Locale.ROOT);
    }
 
    private static boolean isSensitiveKey(String key) {
        String upper = key.toUpperCase(Locale.ROOT);
        for (String token : SENSITIVE) if (upper.contains(token)) return true;
        return false;
    }
}

The trick is the resolve order: env var first, then system property, then default. Caching is keyed on the lookup string so an int and a String read of the same key only hit getenv once.

Usage

import java.time.Duration;
import java.util.logging.Logger;
 
public final class AppConfig {
    private static final Logger LOG = Logger.getLogger(AppConfig.class.getName());
 
    public static final int PORT = EnvironmentUtil.getInt("APP_PORT", 8080);
    public static final Duration HTTP_TIMEOUT = EnvironmentUtil.getDuration("HTTP_TIMEOUT", Duration.ofSeconds(5));
    public static final String DATABASE_URL = EnvironmentUtil.require("DATABASE_URL");
 
    static {
        LOG.info(() -> "Active env: " + EnvironmentUtil.getActiveEnvironment());
        LOG.fine(() -> "Sanitised env: " + EnvironmentUtil.snapshotSanitised());
    }
 
    private AppConfig() {}
}
 
if (EnvironmentUtil.isProd()) {
    enableStrictSecurity();
}

require throws on missing mandatory keys at class init, which is the behaviour you want for DATABASE_URL. The duration literal "30s" parses without any external lib.

Pitfalls

  • Sensitive-key detection is substring-based. MY_SECRET_RECIPE redacts because SECRET is in the name. False positives are fine, false negatives are not, so a wider redaction is the right default. Override SENSITIVE if you genuinely need a name like PUBLIC_KEY to leak through.
  • Cache survives across Spring RefreshScope. If you use Spring Cloud Config and expect refreshed values, hook a RefreshScopeRefreshedEvent listener and call EnvironmentUtil.clearCache().
  • Negative Optional<String> cached. Missing keys store Optional.empty(), so adding the env var later requires clearCache() or a JVM restart. This is intentional for hot lookups; if you set env vars after startup, evict.
  • Duration parser is opinionated. "30" becomes 30 seconds, not 30 millis. Always include a unit for clarity.

Frequently Asked Questions

When should you use this Environment Utility over Spring's Environment abstraction?

Use this Environment Utility in plain Java apps, libraries, static initializers, or anywhere the Spring container is not available yet. In a Spring app you can still delegate to it from `@Configuration` beans for consistent typed parsing across modules, and call `clearCache()` from a `RefreshScope` listener when configuration reloads.

Is reading System.getenv() and System.getProperties() safe to cache?

Yes for the lifetime of the JVM. Environment variables are immutable after process start, and system properties are rarely mutated at runtime. The cache here is concurrent and exposes a `clearCache()` hook for the few cases where you do refresh properties dynamically (e.g. Spring Cloud Config refresh, container reload).

X (Twitter)LinkedIn