NOTE: Drafted with AI assistance based on a coding session; edited by me.
If you have ever worked with Node.js, you probably know about .env files and the dotenv package. Java does not have anything like that built in. So I decided to build my own dotenv loader from scratch, and I learned a lot along the way. This post walks you through how it works and what problems you run into when you try to parse a simple looking file format that is not actually that simple.
You can find the full source code here: dotenv-loader on GitHub
I will not paste the whole codebase in this post. Instead, I want to show you the ideas behind it, with small snippets you can try yourself.
What Even Is a .env File
A .env file holds configuration as simple key value pairs, like this:
DB_HOST=localhost
DB_PORT=5432
Your app reads this file at startup and uses the values instead of hardcoding them. This way, you can change the database host on your laptop without touching the one on your server. Sounds simple, right? It gets more interesting once you add comments, quotes, and variables that reference each other.
Step One: Read the File and Split on the First Equals Sign
The core loop looks like this. Read every line, skip the blank ones and comments, then split the rest on the first = sign.
for (String line : lines) {
if (line.isBlank() || line.startsWith("#")) continue;
int eq = line.indexOf('=');
String key = line.substring(0, eq).trim();
String value = line.substring(eq + 1).trim();
}
Why the first = and not just any =? Because values can contain equals signs too. Think of a database URL like postgres://user:pass@host?ssl=true. If you split on every =, you would break that value into pieces by accident.
Step Two: Quotes Change the Rules
Once you support quotes, things get trickier. A .env file usually allows three styles of value:
UNQUOTED=hello world
DOUBLE="hello world"
SINGLE='hello world'
Double quotes and single quotes behave differently. Inside double quotes, escape sequences work, so \n becomes an actual newline character. Inside single quotes, everything stays exactly as typed. No escapes. This matters a lot if someone has a password with a backslash in it and does not want it accidentally turned into something else.
You cannot just chain .replace("\\n", "\n") calls one after another. Try this value:
"a\\nb"
Is that a literal backslash followed by the letter n, or is it an escaped backslash followed by a newline? You genuinely cannot tell with a naive find and replace approach, because it depends on reading left to right and consuming characters as you go. The fix is to scan character by character with a small state machine, not to run several blind replace passes over the whole string.
Step Three: Variables That Point to Other Variables
Real .env files often reference each other:
HOST=localhost
PORT=5432
URL=http://${HOST}:${PORT}/api
You want URL to resolve into http://localhost:5432/api. To pull this off, you need a small scanner that looks for ${...} (or bare $VAR without braces) and replaces each one with an already parsed value, falling back to the real system environment if nothing local matches.
A few decisions you have to make here, and there is no single correct answer, only tradeoffs:
- What happens if the referenced variable does not exist anywhere? You can throw an error, leave it untouched, or replace it with an empty string. I chose to throw, because a broken config should fail loudly at startup instead of later on vacation.
- Should a value inside single quotes get expanded at all? No. Single quotes mean "leave me alone," so expansion should skip them completely.
- If a key exists both in the file and in the file's own already parsed keys, which one wins during expansion? I decided the file itself always wins over the real system environment, since that is the more predictable behavior.
None of these choices are hard requirements. They are just decisions you need to make on purpose.
Step Four: A Clean Public API
Parsing logic on its own is not very fun to use. You want something like this:
Dotenv dotenv = Dotenv.configure()
.directory("./")
.filename(".env")
.required("DB_HOST", "DB_PASSWORD")
.load();
String host = dotenv.get("DB_HOST");
int port = dotenv.getInt("DB_PORT");
This is the builder pattern. Instead of a constructor with five boolean flags, you chain small, named methods, and each one returns the builder itself so you can keep calling more methods on it. load() is the one method that actually touches the disk and does the real work. Everything before it just configures options.
I also added typed getters like getInt, getBoolean, and getLong, so you are not stuck converting strings by hand every time. If a value cannot convert, it throws a clear exception instead of a confusing NumberFormatException.
Step Five: Playing Nice With System Properties
Some frameworks, Spring Boot for example, read configuration from Java's own System.getProperty(). So I added an option to export loaded values there too:
Dotenv dotenv = Dotenv.configure()
.systemProperties()
.onConflict("LOG_LEVEL", ConflictPolicy.PRESERVE)
.load();
This part needed real care, because System.setProperty changes global state for the whole JVM. If a system property with the same name already exists, you cannot just silently overwrite it and hope for the best. I added three policies: fail loudly on conflict by default, let the .env file win if you say so, or keep whatever was already set. You register these choices per key, so you can be strict about most things and relaxed about a couple of exceptions.
One bug I ran into here taught me something useful. I originally checked for conflicts and wrote the new values in the same loop. That means if key three out of five conflicted, keys one and two had already been written to the system before the exception fired. Half-finished side effects like that are dangerous. The fix was simple once I saw it: validate everything first in one pass, and only write in a second pass once you know nothing will fail.
Step Six: Tests Actually Catch Real Bugs
I want to be honest here. I assumed my required keys check worked because I tested it once by hand early on. Later, after refactoring the code around system properties, that check silently ended up nested inside an if block that only ran when system properties were enabled. My manual testing never caught it, because I was not running that exact combination anymore.
Writing an actual unit test for it caught the regression right away.
@Test
void missingRequiredKeyThrows(@TempDir Path tempDir) throws IOException {
Path envFile = tempDir.resolve(".env");
Files.writeString(envFile, "DB_HOST=localhost\n");
assertThrows(DotenvException.class, () ->
Dotenv.configure()
.directory(tempDir.toString())
.filename(".env")
.required("DB_PASSWORD")
.load()
);
}
JUnit 5's @TempDir annotation is genuinely handy for this kind of thing. It hands you a fresh, empty folder for each test and cleans it up automatically afterward, so you can write real files to disk without cluttering your project or worrying about leftover junk.
What I Would Tell Someone Starting This Project
Parsing something that looks simple, like KEY=VALUE, is a good exercise precisely because it is full of small decisions hiding in plain sight. What happens on an empty value? A duplicate key? A missing closing quote? None of these are hard to handle once you notice them, but you have to actually go looking for them instead of assuming the happy path is the whole story.
If you want to try building something like this yourself, do not start with the variable expansion or the builder pattern. Start with reading a file line by line and splitting on the first equals sign. Get that boring part right first. Everything more interesting builds on top of it.
Full source, tests, and the README with usage examples live here: github.com/KonstantineVashalomidze/dotenv-loader