This lesson closes the module, and settles two debts.
The first goes back to 07-02. CatalogExporter has a method called sanitise() that replaces the semicolons in titles with commas so they do not break the file. You flagged it then as a conscious workaround, and it is: a book called "Java: the language, the machine and the ecosystem" comes back from the file with its title altered. The data is lost on export. Today it gets fixed, by implementing a CSV reader and writer that do properly what the naive split(";") does badly.
The second debt goes back to module 1. LOAN_DAYS = 15, DAILY_RATE = 0.25, MAX_FINE = 20.0, MINOR_THRESHOLD = 7. They have been hard-coded for seven modules, and changing the fine rate —a business decision that can be taken in a half-hour meeting— demands editing the source code, recompiling, packaging and redeploying. Today that ends: those four constants will be read from an external file, with validation and default values.
By the end, BiblioTech will remember between runs and will be configured without recompiling.
try-with-resourceseverywhere, and all the persistence onPathandFilesfrom 07-06.
Contents
- Why text and not binary
- CSV: the structure and its rules
- The naive
splitand why it fails - Escaping and unescaping: doing it properly
- A correct CSV reader for BiblioTech
- A correct CSV writer
- The separator and the decimal point in Europe
- Exporting the catalogue and the loans to CSV
- An honest recommendation: when to use a library
Properties: what it is and how it worksloadandstore, with a charset and no surprises- Chained default properties
- The configuration hierarchy and its precedence
- BiblioTech's
Configurationclass - The four constants stop being constants
- Never store credentials in the repository
- Other formats: XML, JSON and YAML
- Common Mistakes and Tips
- Exercises
- Conclusion and close of the module
- Why text and not binary
You already know the two alternatives: the binary format of DataOutputStream (07-03) and native serialisation (07-05), as against plain text. The choice is not a matter of taste.
| Text format (CSV, Properties, JSON) | Binary format (serialisation, DataStream) |
|
|---|---|---|
| Readable by a person | Yes, with any editor | No |
| Diffable in version control | Yes: git diff shows what changed |
No: a blob that changed entirely |
| Editable by hand | Yes, in an emergency | No |
| Language-independent | Yes: Python, Excel, grep |
Java only |
| Resilient to class changes | Yes | No: InvalidClassException |
| Safe with foreign data | Yes: it produces strings, it instantiates nothing | No |
| Size | Larger | Smaller |
| Speed | Slower: it has to be parsed | Faster |
| Types | Everything is text: convert and validate | The types travel |
| Complex structures | Difficult: references, nesting | Automatic |
The three rows that decide it, in order of importance:
1. Diffability in version control. If BiblioTech's catalogue is in CSV and somebody changes a rate, the git diff shows exactly which line changed and who changed it. With a binary file, the diff says "the file changed" and the investigation ends there. For data that is versioned, this is priceless.
2. Language independence. The catalogue CSV file opens in a spreadsheet, is processed with a Python script, is filtered with grep and is sent to a supplier who does not use Java. The serialised file is read only by your application, and only as long as you do not change the classes.
3. Security. A CSV produces strings; you decide what object to build from them, going through your constructors and your validations from 06-03. Serialisation builds objects from class names that come in the file, with the consequences of section 12 of 07-05.
What you pay in exchange:
- Size: an
intthat takes 4 bytes in binary takes up to 11 in text. - Speed: every field has to be parsed.
- Types: everything arrives as a
Stringand has to be converted and validated. - Structures: the relationships between objects have to be represented by hand, with identifiers.
For BiblioTech's catalogue —a few thousand lines that open in Excel, are versioned and are sent to suppliers— the trade is clearly favourable. For a cache of a million objects with cross-references, it would not be.
- CSV: the structure and its rules
CSV stands for Comma-Separated Values. Its basic structure fits into three sentences: one line per record, fields separated by commas, an optional first line with the column names.
type,reference,title,author,year,available
BOOK,978-0000000001,Effective Java,Joshua Bloch,2018,true
BOOK,978-0000000002,Design Patterns,Erich Gamma,1994,false
BOOK,978-0000000003,Refactoring,Martin Fowler,1999,trueAnd that is where the easy part ends. Because CSV is not a well-defined format. For decades it was an informal convention, with variants by tool and by country. The attempt at standardisation —RFC 4180, from 2005— came late and not everybody follows it.
The RFC 4180 rules, which are the ones we will implement:
- Each record on one line, terminated by CRLF.
- The header is optional, with the same number of fields as the data.
- Fields are separated by commas. The last one has no trailing comma.
- A field may be enclosed in double quotes.
- A field containing a comma, a quote or a line break MUST be enclosed in double quotes.
- A quote inside a quoted field is written DOUBLED.
Rules 4, 5 and 6 are the ones the naive split ignores, and they are the ones that cause all the problems.
Examples of each case:
title,author,notes
Effective Java,Bloch,No notes
"Java: the language, the machine and the ecosystem",Bloch,"Contains a comma"
"The book called ""Refactoring""",Fowler,"Contains doubled quotes"
"Title with
a line break",Author,"A field can span several physical lines"
No quotes,,"Empty field in the middle"Look at the penultimate line: a single logical record spans two physical lines. This completely breaks the idea of "one readLine() per record", and it is the reason a correct CSV reader cannot simply be written with the canonical loop of 07-04.
- The naive
split and why it fails
split and why it failsThis is the code almost everybody writes the first time:
The six cases where it fails, with what each one produces:
| CSV line | What it should give | What split(",") gives |
|---|---|---|
"Java: the language, the machine",Bloch,2018 |
3 fields | 4 fields, the title split |
"The book ""Refactoring""",Fowler |
2 fields | 2 fields, but with extra quotes |
"Title with\na break",Author |
1 record of 2 fields | 2 broken records |
Effective Java,,2018 |
3 fields, the 2nd empty | 3 fields. Correct by accident |
Effective Java,Bloch, |
3 fields, the 3rd empty | 2 fields: it discards the last empty one |
\uFEFFtype,reference (with a BOM) |
type |
\uFEFFtype: it matches nothing |
The three most serious:
The field with a separator inside. It is the case that breaks BiblioTech's catalogue, and it is the reason for the sanitise() of 07-02:
String line = "\"Java: the language, the machine and the ecosystem\",Bloch,2018";
String[] fields = line.split(",");
// fields.length == 4, not 3
// fields[0] == "\"Java: the language"
// fields[1] == " the machine and the ecosystem\""
// fields[2] == "Bloch"
// fields[3] == "2018"And the worst part is not that it gives four fields: it is that if your code checks fields.length != 3 and discards the line, you lose the book with no more warning than a line in the report. If it does not check, you store "Java: the language as the title and Bloch as the year.
The empty field at the end. You already saw this in 07-01:
"Effective Java,Bloch,".split(","); // 2 elements: it discards trailing empties
"Effective Java,Bloch,".split(",", -1); // 3 elements: correctThe -1 is mandatory, and it is constantly forgotten.
The BOM. A UTF-8 file saved by Excel on Windows usually begins with three invisible bytes —EF BB BF, the byte order mark— which decode as the character \uFEFF. It is invisible in any editor, and it makes the first header column match nothing:
String first = fields[0]; // "\uFEFFtype", not "type"
first.equals("type"); // false
first.length(); // 5, not 4It is one of the most baffling failures there is, because the file looks perfect. The fix is to strip it when reading the first line, and it is in section 5.
And a question that always comes up: can it not be solved with a regular expression? Several circulate, of the form ,(?=(?:[^\"]*\"[^\"]*\")*[^\"]*$). They work for the simple case and fail with escaped quotes and with line breaks inside fields, besides being unreadable and inefficient. The correct solution is a small state machine, and it takes forty lines.
- Escaping and unescaping: doing it properly
Before the complete reader, the two elementary operations.
Escaping (when writing): deciding whether a field needs quotes and adding them.
/**
* Escapes a field per RFC 4180.
*
* A field needs quotes if it contains the separator, a quote, a line break,
* or spaces at the beginning or the end (which many tools trim without
* warning).
*
* Interior quotes are DOUBLED, not preceded by a backslash: CSV does not
* use backslashes.
*/
public static String escape(String field, char separator) {
if (field == null) {
return ""; // null and empty are written the same (section 5)
}
boolean needsQuotes =
field.indexOf(separator) >= 0
|| field.indexOf('"') >= 0
|| field.indexOf('\n') >= 0
|| field.indexOf('\r') >= 0
|| field.startsWith(" ")
|| field.endsWith(" ");
if (!needsQuotes) {
return field;
}
// Double the interior quotes and wrap in quotes
return '"' + field.replace("\"", "\"\"") + '"';
}Unescaping (when reading): removing the enclosing quotes and undoing the doubling.
/** Undoes the escaping of an already-extracted field. */
public static String unescape(String field) {
if (field == null || field.isEmpty()) {
return "";
}
String clean = field.trim();
if (clean.length() >= 2 && clean.startsWith("\"") && clean.endsWith("\"")) {
clean = clean.substring(1, clean.length() - 1);
clean = clean.replace("\"\"", "\"");
}
return clean;
}The complete edge-case table, which is the specification of what has to be met:
| Value in memory | Written in CSV | Read back |
|---|---|---|
Effective Java |
Effective Java |
Effective Java |
Java: the language, the machine |
"Java: the language, the machine" |
the same |
The book "Refactoring" |
"The book ""Refactoring""" |
the same |
Line 1\nLine 2 |
"Line 1\nLine 2" |
the same |
| `` (empty string) | (nothing) | `` |
null |
(nothing) | `` (the distinction is lost) |
with spaces |
" with spaces " |
the same |
field;with;semicolons with separator , |
field;with;semicolons |
the same |
field;with;semicolons with separator ; |
"field;with;semicolons" |
the same |
Look at the null row. CSV does not distinguish null from the empty string: both are written the same and both come back as an empty string. It is a real limitation of the format, not a defect of the implementation. If that distinction matters in your domain, there are two ways out: use a documented sentinel value —\N is the PostgreSQL convention— or use a format that does have nulls, such as JSON (11-07). What you cannot do is pretend the problem does not exist.
- A correct CSV reader for BiblioTech
The complete implementation, with a two-state machine: inside quotes and outside quotes.
package com.nexussoftware.bibliotech.infrastructure;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.Reader;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
/**
* CSV reader conforming to RFC 4180.
*
* It handles correctly:
* - separators inside quoted fields
* - quotes escaped by doubling
* - line breaks inside a field (a record spans several lines)
* - empty fields, including the last one
* - the BOM at the start of the file
*
* It does NOT handle (and it says so honestly):
* - the distinction between null and the empty string: CSV does not have it
* - encodings other than the one it is given
*
* It is Closeable: it closes the Reader it wraps (06-06).
*/
public class CsvReader implements AutoCloseable {
/** Byte order mark that Excel writes at the start of UTF-8 files. */
private static final char BOM = '\uFEFF';
private static final char QUOTE = '"';
private final BufferedReader reader;
private final char separator;
private boolean firstRead = true;
private int recordNumber = 0;
private int physicalLineNumber = 0;
public CsvReader(Reader source, char separator) {
Objects.requireNonNull(source, "The source cannot be null");
this.reader = (source instanceof BufferedReader br)
? br
: new BufferedReader(source); // do not wrap twice (07-04)
this.separator = separator;
}
public CsvReader(Reader source) {
this(source, ',');
}
/**
* Reads the next record.
*
* A record can span SEVERAL physical lines if some field has a line break
* inside it. That is why one readLine() per record is not enough.
*
* @return the already-unescaped fields, or null if there are no more records
* @throws CsvFormatException if the record is malformed
*/
public List<String> next() throws IOException {
String line = reader.readLine();
if (line == null) {
return null; // the end (07-04)
}
physicalLineNumber++;
// Strip the BOM ONLY from the first line
if (firstRead) {
firstRead = false;
if (!line.isEmpty() && line.charAt(0) == BOM) {
line = line.substring(1);
}
}
List<String> fields = new ArrayList<>();
StringBuilder field = new StringBuilder();
boolean insideQuotes = false;
int index = 0;
while (true) {
if (index >= line.length()) {
if (!insideQuotes) {
break; // end of the record
}
// We are INSIDE quotes: the field continues on the next
// physical line. The line break is restored.
String continuation = reader.readLine();
if (continuation == null) {
throw new CsvFormatException(physicalLineNumber,
"The file ends with an unclosed quote");
}
physicalLineNumber++;
field.append('\n');
line = continuation;
index = 0;
continue;
}
char c = line.charAt(index);
if (insideQuotes) {
if (c == QUOTE) {
// A quote inside quotes: either it closes, or it is doubled
if (index + 1 < line.length() && line.charAt(index + 1) == QUOTE) {
field.append(QUOTE); // doubled: one literal quote
index += 2;
} else {
insideQuotes = false; // it closes the field
index++;
}
} else {
field.append(c);
index++;
}
} else {
if (c == QUOTE && field.isEmpty()) {
// Quotes at the START of the field: they open
insideQuotes = true;
index++;
} else if (c == separator) {
fields.add(field.toString());
field.setLength(0);
index++;
} else {
field.append(c);
index++;
}
}
}
fields.add(field.toString()); // the last field, always
recordNumber++;
return fields;
}
/**
* Reads all the remaining records.
*
* WARNING: it loads the whole file into memory (07-01). For large files,
* use next() in a loop.
*/
public List<List<String>> readAll() throws IOException {
List<List<String>> records = new ArrayList<>();
List<String> record;
while ((record = next()) != null) {
records.add(record);
}
return records;
}
public int getRecordNumber() { return recordNumber; }
public int getPhysicalLineNumber() { return physicalLineNumber; }
@Override
public void close() throws IOException {
reader.close();
}
/** Format failure with the exact physical line (06-04). */
public static class CsvFormatException extends IOException {
private static final long serialVersionUID = 1L;
private final int line;
public CsvFormatException(int line, String message) {
super("Line " + line + ": " + message);
this.line = line;
}
public int getLine() { return line; }
}
}A check with the difficult cases:
public class CsvReaderTest {
public static void main(String[] args) throws IOException {
String content = String.join("\n",
"title,author,year",
"Effective Java,Joshua Bloch,2018",
"\"Java: the language, the machine\",Bloch,2018",
"\"The book \"\"Refactoring\"\"\",Fowler,1999",
"\"Title with",
"a line break\",Author,2020",
"No quotes,,2021",
"Last field empty,Author,");
try (CsvReader csv = new CsvReader(new java.io.StringReader(content))) {
List<String> record;
while ((record = csv.next()) != null) {
System.out.printf("[%d] %d fields: %s%n",
csv.getRecordNumber(), record.size(), record);
}
}
}
}Output:
[1] 3 fields: [title, author, year]
[2] 3 fields: [Effective Java, Joshua Bloch, 2018]
[3] 3 fields: [Java: the language, the machine, Bloch, 2018]
[4] 3 fields: [The book "Refactoring", Fowler, 1999]
[5] 3 fields: [Title with
a line break, Author, 2020]
[6] 3 fields: [No quotes, , 2021]
[7] 3 fields: [Last field empty, Author, ]All seven records give three fields. Compare with split(","), which would have given 4, 3, 2 (badly split), 3 and 2 respectively. Look especially at record 5: it spans two physical lines and is read as a single record, with the line break preserved inside the field.
- A correct CSV writer
The symmetrical side:
package com.nexussoftware.bibliotech.infrastructure;
import java.io.IOException;
import java.io.Writer;
import java.util.List;
import java.util.Objects;
/**
* CSV writer conforming to RFC 4180.
*
* It quotes and escapes whatever is needed, so that what is written can be
* read back with CsvReader without losing anything.
*/
public class CsvWriter implements AutoCloseable {
private static final char QUOTE = '"';
private final Writer output;
private final char separator;
private final String lineEnding;
private int recordsWritten = 0;
/**
* @param output destination, normally a BufferedWriter (07-04)
* @param separator ',' or ';'
* @param lineEnding "\n" for files that are versioned; CRLF if the
* consumer demands it. RFC 4180 asks for CRLF.
*/
public CsvWriter(Writer output, char separator, String lineEnding) {
this.output = Objects.requireNonNull(output, "The output cannot be null");
this.separator = separator;
this.lineEnding = Objects.requireNonNull(lineEnding);
}
public CsvWriter(Writer output) {
this(output, ',', "\n");
}
/** Writes a complete record. */
public void writeRecord(List<String> fields) throws IOException {
Objects.requireNonNull(fields, "The fields cannot be null");
for (int i = 0; i < fields.size(); i++) {
if (i > 0) {
output.write(separator);
}
output.write(escape(fields.get(i)));
}
output.write(lineEnding);
recordsWritten++;
}
/** Varargs variant, convenient for short calls. */
public void writeRecord(String... fields) throws IOException {
writeRecord(List.of(fields));
}
/** Writes the header. It is an ordinary record; the name is documentation. */
public void writeHeader(String... names) throws IOException {
writeRecord(names);
}
/**
* Escapes a field per RFC 4180.
*
* It is quoted if it contains the separator, a quote, a line break, or
* spaces at the ends (which many tools trim).
*/
private String escape(String field) {
if (field == null || field.isEmpty()) {
return "";
}
boolean needs = field.indexOf(separator) >= 0
|| field.indexOf(QUOTE) >= 0
|| field.indexOf('\n') >= 0
|| field.indexOf('\r') >= 0
|| field.charAt(0) == ' '
|| field.charAt(field.length() - 1) == ' ';
if (!needs) {
return field;
}
return QUOTE + field.replace("\"", "\"\"") + QUOTE;
}
public int getRecordsWritten() { return recordsWritten; }
/** Explicit flush: useful if the consumer reads while we write (07-02). */
public void flush() throws IOException {
output.flush();
}
@Override
public void close() throws IOException {
output.close(); // the Writer's close does a flush (07-02)
}
}A round-trip test, which is the check that really matters:
public class RoundTripTest {
public static void main(String[] args) throws IOException {
List<List<String>> originals = List.of(
List.of("title", "author", "notes"),
List.of("Effective Java", "Bloch", ""),
List.of("Java: the language, the machine", "Bloch", "with a comma"),
List.of("The book \"Refactoring\"", "Fowler", "with quotes"),
List.of("Title with\na break", "Author", "with a break"),
List.of(" spaces ", "Author", "with spaces"));
// Write to memory (07-03)
java.io.StringWriter memory = new java.io.StringWriter();
try (CsvWriter writer = new CsvWriter(memory)) {
for (List<String> r : originals) {
writer.writeRecord(r);
}
}
System.out.println("=== CSV GENERATED ===");
System.out.println(memory);
// Read it back
System.out.println("=== ROUND-TRIP CHECK ===");
try (CsvReader reader = new CsvReader(new java.io.StringReader(memory.toString()))) {
int i = 0;
List<String> read;
boolean allGood = true;
while ((read = reader.next()) != null) {
boolean same = read.equals(originals.get(i));
allGood &= same;
System.out.printf(" [%d] %s %s%n", i, same ? "OK " : "FAIL", read);
i++;
}
System.out.println(allGood
? " All the records come back IDENTICAL."
: " THERE IS DATA LOSS.");
}
}
}Output:
=== CSV GENERATED ===
title,author,notes
Effective Java,Bloch,
"Java: the language, the machine",Bloch,with a comma
"The book ""Refactoring""",Fowler,with quotes
"Title with
a break",Author,with a break
" spaces ",Author,with spaces
=== ROUND-TRIP CHECK ===
[0] OK [title, author, notes]
[1] OK [Effective Java, Bloch, ]
[2] OK [Java: the language, the machine, Bloch, with a comma]
[3] OK [The book "Refactoring", Fowler, with quotes]
[4] OK [Title with
a break, Author, with a break]
[5] OK [ spaces , Author, with spaces]
All the records come back IDENTICAL.The round-trip test is the only serious way of validating a format. Write, read and compare with the original. If something comes back different, the format loses data, and the best that can happen is that you discover it here and not in production. This test is what was missing from the
sanitise()of 07-02: had you done it then, the failure would have shown up on the first try.
- The separator and the decimal point in Europe
A practical problem that appears as soon as the file is opened in a spreadsheet configured for a European locale.
The separator. CSV says "comma", but Excel with a Spanish or German configuration uses a semicolon by default, because the comma is taken as the decimal separator. A comma-separated CSV opens in a European Excel as a single column.
The decimal point. In Spanish, 0,25. In English, 0.25. And if the field separator is the comma and the decimal separator is too, the conflict is immediate:
Are these two fields with the rate 0,25, or three fields with 0 and 25? There is no way of telling. The file is ambiguous.
The three strategies, with their consequences:
| Strategy | Separator | Decimal | Opens in European Excel | Portable |
|---|---|---|---|---|
| International standard | , |
. |
Badly: one column | Yes |
| Continental convention | ; |
, |
Well | No |
| Mixed | ; |
. |
Well | Yes, with a caveat |
BiblioTech's decision, and the general recommendation:
For files consumed by another program: separator
,, decimal.,Locale.ROOT. It is the standard and it is not up for debate. For files a person opens in a European Excel: separator;. And for the decimal, if your own program is going to reread the file, keep the dot and document it; if it is only for reading, the comma.
How it is controlled in the code, picking up 07-01 and 07-02:
import java.util.Locale;
// WRITING: pin the Locale so that the decimal is ALWAYS the dot
String rate = String.format(Locale.ROOT, "%.2f", 0.25); // "0.25"
// Without a Locale, on a Spanish machine:
String bad = String.format("%.2f", 0.25); // "0,25" <-- ambiguous
// READING: always parse with a dot
double value = Double.parseDouble("0.25"); // Double.parseDouble ALWAYS uses a dot
// If the file has a decimal comma, it has to be normalised first
double valueEs = Double.parseDouble("0,25".replace(',', '.'));A useful detail: Double.parseDouble always uses the dot, regardless of the system Locale. It is String.format and Scanner that are locale-sensitive. That asymmetry causes the classic failure of writing 0,25 with format and not being able to reread it with parseDouble.
And a practical defence when reading:
/** Parses a decimal accepting a dot or a comma. Tolerant reading, strict writing. */
private static double parseDecimal(String text, String field, int line) {
if (text == null || text.isBlank()) {
return 0.0;
}
try {
return Double.parseDouble(text.trim().replace(',', '.'));
} catch (NumberFormatException e) {
throw new IllegalArgumentException(String.format(
"Line %d: the field '%s' is not a valid number: '%s'",
line, field, text), e); // chain the cause (06-03)
}
}Tolerant when reading, strict when writing. It is a general principle of format design, and here it saves a lot of trouble: accept whatever arrives if you can understand it, but always write in the canonical form.
- Exporting the catalogue and the loans to CSV
Now for real: CatalogExporter with no workarounds.
package com.nexussoftware.bibliotech.service;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.logging.Level;
import java.util.logging.Logger;
import com.nexussoftware.bibliotech.domain.Book;
import com.nexussoftware.bibliotech.domain.Material;
import com.nexussoftware.bibliotech.domain.DuplicateReferenceException;
import com.nexussoftware.bibliotech.infrastructure.CsvWriter;
import com.nexussoftware.bibliotech.infrastructure.CsvReader;
/**
* Exports and imports the BiblioTech catalogue in CSV per RFC 4180.
*
* It replaces the 07-02 version, which "sanitised" the titles by replacing
* the separators with commas and LOST THE DATA. Now a title such as
* "Java: the language, the machine and the ecosystem" is exported and
* imported back identical.
*
* Persistence on NIO.2 (07-06), with atomic writing.
*/
public class CsvCatalogExporter {
private static final Logger LOG =
Logger.getLogger(CsvCatalogExporter.class.getName());
private static final java.nio.charset.Charset CHARSET = StandardCharsets.UTF_8;
/** Comma and decimal dot: the standard. For a European Excel, ';' (section 7). */
private static final char SEPARATOR = ',';
private static final String[] CATALOG_HEADER =
{ "type", "reference", "title", "author", "year", "available" };
private static final String[] LOANS_HEADER =
{ "reference", "material", "employee", "start_day",
"return_day", "fine" };
private final Path directory;
public CsvCatalogExporter(Path directory) throws IOException {
this.directory = Objects.requireNonNull(directory).toAbsolutePath().normalize();
Files.createDirectories(this.directory);
}
// ---------------------- EXPORTING ----------------------
/**
* Exports the catalogue to CSV, atomically (07-06).
*
* @return number of materials exported
*/
public int exportCatalog(Catalog catalog) throws IOException {
Objects.requireNonNull(catalog, "The catalogue cannot be null");
List<Material> materials = catalog.list();
Path target = directory.resolve("catalog.csv");
Path temp = target.resolveSibling(target.getFileName() + ".tmp");
boolean completed = false;
try {
try (BufferedWriter output = Files.newBufferedWriter(temp, CHARSET,
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING,
StandardOpenOption.WRITE);
CsvWriter csv = new CsvWriter(output, SEPARATOR, "\n")) {
csv.writeHeader(CATALOG_HEADER);
for (Material m : materials) {
csv.writeRecord(toRecord(m));
}
}
Files.move(temp, target,
StandardCopyOption.REPLACE_EXISTING,
StandardCopyOption.ATOMIC_MOVE);
completed = true;
LOG.info(() -> String.format("Catalogue exported: %d materials to %s",
materials.size(), target));
} catch (AtomicMoveNotSupportedException e) {
LOG.warning(() -> "No ATOMIC_MOVE; non-atomic replacement");
Files.move(temp, target, StandardCopyOption.REPLACE_EXISTING);
completed = true;
} finally {
if (!completed) {
Files.deleteIfExists(temp); // compensation (06-05)
}
}
return materials.size();
}
/**
* Converts a material into a CSV record.
*
* NO sanitising: the writer quotes whatever is needed. The title travels
* INTACT, with its commas, its quotes and its colons.
*/
private List<String> toRecord(Material m) {
List<String> fields = new ArrayList<>(6);
if (m instanceof Book book) { // 03-06
fields.add("BOOK");
fields.add(book.getIsbn());
fields.add(book.getTitle());
fields.add(book.getAuthor());
fields.add(String.valueOf(book.getPublicationYear()));
} else {
fields.add(m.getType().toUpperCase(Locale.ROOT));
fields.add(m.getReference());
fields.add(m.getTitle());
fields.add("");
fields.add("");
}
fields.add(String.valueOf(m.isAvailable()));
return fields;
}
// ---------------------- IMPORTING ----------------------
/** Result of an import (06-07, 07-04). */
public record ImportResult(int read, int imported, int discarded,
List<String> errors) {
public String summary() {
StringBuilder sb = new StringBuilder();
sb.append(String.format(
"CSV import: %d records read, %d imported, %d discarded%n",
read, imported, discarded));
int n = Math.min(10, errors.size());
for (int i = 0; i < n; i++) {
sb.append(" ").append(errors.get(i)).append('\n');
}
if (errors.size() > n) {
sb.append(String.format(" ... and %d more%n", errors.size() - n));
}
return sb.toString();
}
}
/**
* Imports the catalogue from CSV.
*
* The policy of 06-07 and 07-04: a bad line is discarded and counted;
* a missing file degrades to an empty result with no exception.
*/
public ImportResult importCatalog(Catalog catalog) throws IOException {
Objects.requireNonNull(catalog, "The catalogue cannot be null");
Path source = directory.resolve("catalog.csv");
List<String> errors = new ArrayList<>();
int read = 0, imported = 0;
if (Files.notExists(source)) {
LOG.warning(() -> "There is no CSV catalogue at " + source
+ "; starting with an empty catalogue");
return new ImportResult(0, 0, 0, errors);
}
try (BufferedReader reader = Files.newBufferedReader(source, CHARSET);
CsvReader csv = new CsvReader(reader, SEPARATOR)) {
List<String> header = csv.next();
if (header == null) {
LOG.warning(() -> "The file " + source + " is empty");
return new ImportResult(0, 0, 0, errors);
}
checkHeader(header, errors);
List<String> record;
while ((record = csv.next()) != null) {
read++;
int number = csv.getRecordNumber();
try {
Material material = toMaterial(record, number);
if (material != null) {
catalog.register(material);
imported++;
}
} catch (IllegalArgumentException e) {
errors.add(e.getMessage());
} catch (DuplicateReferenceException e) {
errors.add("Record " + number + ": " + e.getMessage());
}
}
}
int discarded = read - imported;
LOG.info(() -> String.format("Catalogue imported: %d of %d records",
imported, read));
return new ImportResult(read, imported, discarded, errors);
}
/** A change of columns is a warning, not a fatal failure. */
private void checkHeader(List<String> header, List<String> errors) {
if (header.size() != CATALOG_HEADER.length) {
errors.add(String.format(
"Header with %d columns; %d were expected (%s). "
+ "The import is attempted anyway.",
header.size(), CATALOG_HEADER.length,
String.join(", ", CATALOG_HEADER)));
}
}
/** Builds the material validating each field. Throws if the record is invalid. */
private Material toMaterial(List<String> fields, int number) {
if (fields.size() < 5) {
throw new IllegalArgumentException(String.format(
"Record %d: %d fields, at least 5 were expected", number, fields.size()));
}
String type = fields.get(0).trim().toUpperCase(Locale.ROOT);
if (!"BOOK".equals(type)) {
throw new IllegalArgumentException(
"Record " + number + ": unsupported type '" + type + "'");
}
String isbn = fields.get(1).trim();
String title = fields.get(2).trim();
String author = fields.get(3).trim();
if (isbn.isEmpty() || title.isEmpty()) {
throw new IllegalArgumentException(
"Record " + number + ": ISBN or title empty");
}
int year;
try {
year = Integer.parseInt(fields.get(4).trim());
} catch (NumberFormatException e) {
throw new IllegalArgumentException(String.format(
"Record %d: the year '%s' is not a number",
number, fields.get(4)), e); // with the cause (06-03)
}
boolean available = fields.size() < 6
|| !"false".equalsIgnoreCase(fields.get(5).trim());
return new Book(title, author.isEmpty() ? "Unknown" : author,
isbn, year, available);
}
// ---------------------- LOANS ----------------------
/** Exports the loan register, with the decimals in Locale.ROOT. */
public int exportLoans(List<Loan> loans) throws IOException {
Path target = directory.resolve("loans.csv");
try (BufferedWriter output = Files.newBufferedWriter(target, CHARSET,
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING,
StandardOpenOption.WRITE);
CsvWriter csv = new CsvWriter(output, SEPARATOR, "\n")) {
csv.writeHeader(LOANS_HEADER);
for (Loan l : loans) {
csv.writeRecord(
l.getReference(),
l.getMaterial().getReference(),
l.getBorrower().getIdentifier(),
String.valueOf(l.getStartDay()),
String.valueOf(l.isReturned() ? l.getReturnDay() : -1),
// Locale.ROOT: decimal DOT ALWAYS, so it can be reread
String.format(Locale.ROOT, "%.2f", l.calculateFine(l.getStartDay())));
}
}
LOG.info(() -> "Loans exported: " + loans.size() + " to " + target);
return loans.size();
}
}The resulting file, with the problematic title included:
type,reference,title,author,year,available
BOOK,978-0000000001,Effective Java,Joshua Bloch,2018,true
BOOK,978-0000000002,Design Patterns,Erich Gamma,1994,false
BOOK,978-0000000003,Refactoring,Martin Fowler,1999,true
BOOK,978-0000000004,"Java: the language, the machine and the ecosystem",Bloch,2024,trueThe title on the last line travels intact, with its comma, and comes back identical. That is the 07-02 debt settled.
- An honest recommendation: when to use a library
You have just written a correct CSV reader in about a hundred lines, and it works. Now the professional recommendation, which may sound contradictory:
In production, use a CSV library. OpenCSV and Apache Commons CSV are the usual ones in Java, and they cover cases your implementation does not.
What they do and yours does not:
| Aspect | Your implementation | A mature library |
|---|---|---|
| Basic RFC 4180 | Yes | Yes |
| Automatic separator detection | No | Yes |
| Mapping to objects by annotations | No | Yes |
| Headers with access by name | No | Yes |
| BOM detection and removal | Yes, the first line | Yes, complete |
| Dialect formats (Excel, MySQL, TDF) | No | Yes |
| Tuned performance | Reasonable | Highly optimised |
| Exotic edge cases | The ones you foresaw | Thousands of tested real cases |
| Maintenance | Yours | The community's |
So why implement it? For three reasons that are worth it:
- You now understand the problem. When a library gives you an odd result, you will know whether the failure is in your file, in the separator configuration or in an edge case of the format. Somebody who has never implemented it cannot diagnose that.
- You know what to ask the library. How does it handle the BOM? What does it do with empty fields? Does it distinguish
nullfrom""? You now know those questions exist and why they matter. - Sometimes you cannot use a library. A restricted environment, an application that cannot add dependencies, a slightly different proprietary format. Then you write this, and you know it works because you have done the round-trip test.
The essential Java libraries —including Jackson for JSON, OpenCSV and Commons CSV— are covered in 11-07. And there is a general lesson that goes beyond CSV:
Implement it once to understand it; use the library in production. It applies to almost everything: parsers, caches, queues, sorting algorithms. Understanding the mechanism makes you a better professional; reimplementing it in production makes you responsible for maintaining it.
Properties: what it is and how it works
Properties: what it is and how it worksjava.util.Properties is the classic form of configuration in Java. It is a Map<String, String> with the ability to load and save itself to a file.
# BiblioTech configuration - Nexus Software
# This file is loaded at start-up and does NOT require recompiling the application
# --- Loan rules ---
bibliotech.loan.days = 15
bibliotech.max.loans = 3
# --- Fines ---
bibliotech.daily.rate = 0.25
bibliotech.max.fine = 20.0
bibliotech.minor.threshold = 7
# --- Paths ---
bibliotech.data.directory = data
bibliotech.data.catalog = catalog.csv
# --- Logging ---
bibliotech.log.level = INFOThe format rules:
| Element | Rule |
|---|---|
| Key-value separator | =, : or a space. = is the usual one |
Spaces around the = |
Ignored |
| Comments | Lines starting with # or ! |
| Blank lines | Ignored |
| Line continuation | \ at the end |
| Escapes | \n, \t, \\, \:, \=, \uXXXX |
| Key with spaces | They have to be escaped: my\ key = value |
| Everything is text | There are no types: convert and validate |
A surprising detail: trailing spaces in the value ARE preserved. key = value stores "value " with three spaces. Since they are invisible, the failure is baffling. That is why you should always trim() when reading.
And the Windows path trap:
# WRONG: \d is interpreted as an escape
path = C:\data\catalog.csv
# RIGHT: doubled backslash
path = C:\\data\\catalog.csv
# BETTER: ordinary slashes, which Java accepts on Windows (07-01)
path = C:/data/catalog.csvThe basic API:
import java.util.Properties;
Properties props = new Properties();
props.setProperty("bibliotech.loan.days", "15");
String days = props.getProperty("bibliotech.loan.days"); // "15"
String missing = props.getProperty("does.not.exist"); // null
String withDefault = props.getProperty("does.not.exist", "a default"); // the default
props.containsKey("bibliotech.loan.days"); // true
props.stringPropertyNames(); // Set<String> with all the keys
props.size();Always use
getPropertywith a default value. The one-argument version returnsnull, and thatnullends up in anInteger.parseIntwhich throwsNumberFormatExceptionwith the messagenull, which says absolutely nothing about which key was missing. It is thenullas a return value that 06-07 banished.
And a warning about the inheritance of Properties: it extends Hashtable<Object, Object>, which gives it get/put methods that accept any object and bypass the default-value mechanism. Always use getProperty/setProperty, never get/put. It is a 1996 design defect that is still there.
load and store, with a charset and no surprises
load and store, with a charset and no surprisesThe historical encoding problem
Up to Java 8, Properties.load(InputStream) read in ISO-8859-1, with no way of changing it. Characters outside that set had to be written as Unicode escapes:
# Before Java 9, the only way to write "cafe" with an acute accent:
message.welcome = Welcome to the BiblioTech caf\u00e9Unreadable and awkward, to the point that the JDK shipped a tool (native2ascii) to convert files automatically.
Since Java 9, Properties.load(InputStream) detects UTF-8 and only falls back to ISO-8859-1 if the bytes are not valid UTF-8. And since Java 6 there have been overloads accepting a Reader, which are the ones to use:
import java.io.BufferedReader;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Properties;
public class LoadProperties {
/** Loads with an EXPLICIT charset. The correct form, with no ambiguity. */
public static Properties load(Path file) throws IOException {
Properties props = new Properties();
try (BufferedReader reader = Files.newBufferedReader(
file, StandardCharsets.UTF_8)) { // 07-06
props.load(reader); // load(Reader): the Reader decides the charset
}
return props;
}
/** Saves with an explicit charset. */
public static void save(Properties props, Path file, String comment)
throws IOException {
try (java.io.BufferedWriter output = Files.newBufferedWriter(
file, StandardCharsets.UTF_8,
java.nio.file.StandardOpenOption.CREATE,
java.nio.file.StandardOpenOption.TRUNCATE_EXISTING,
java.nio.file.StandardOpenOption.WRITE)) {
props.store(output, comment);
}
}
}Always use the
Reader/Writeroverloads, not theInputStream/OutputStreamones. With aReaderyou decide the charset explicitly, just as in 07-01. With anInputStreamyou depend on the JVM's heuristic.
What store writes:
#Configuration generated by BiblioTech
#Wed Aug 05 09:14:22 CEST 2026
bibliotech.daily.rate=0.25
bibliotech.loan.days=15
bibliotech.data.directory=dataTwo observations about that output:
storeadds a line with the date. That makes the file change on every save even if the content is identical, which generates noise in version control. If the file is versioned, consider writing it yourself withCsvWriter... or rather with aBufferedWriterand your own format.storepreserves neither the order nor the original comments.Propertiesis aHashtable: unordered. If you load a carefully commented file and save it again, you lose all the comments and the order. That is why a project's configuration files are edited by hand and read withload;storeis reserved for configuration generated by the application.
And a very useful alternative for configuration that travels inside the .jar:
/** Loads from the classpath: the file goes inside the jar and cannot be lost. */
public static Properties loadFromClasspath(String resource) throws IOException {
Properties props = new Properties();
try (java.io.InputStream input =
Configuration.class.getResourceAsStream(resource)) {
if (input == null) {
throw new java.io.FileNotFoundException(
"The resource '" + resource + "' was not found on the classpath");
}
try (java.io.Reader reader = new java.io.InputStreamReader(
input, StandardCharsets.UTF_8)) { // the BRIDGE from 07-03
props.load(reader);
}
}
return props;
}It is the usual pattern for the default values: they go inside the .jar, they are always there, and the external file only overrides them.
- Chained default properties
Properties has a little-known and very useful mechanism: one instance can have another as a fallback.
// Level 1: default values, in the code
Properties defaults = new Properties();
defaults.setProperty("bibliotech.loan.days", "15");
defaults.setProperty("bibliotech.daily.rate", "0.25");
defaults.setProperty("bibliotech.max.fine", "20.0");
// Level 2: the file, WITH the defaults as a fallback
Properties configuration = new Properties(defaults);
configuration.load(reader); // the file only brings 'days'
configuration.getProperty("bibliotech.loan.days"); // from the file
configuration.getProperty("bibliotech.daily.rate"); // "0.25", from the default
configuration.getProperty("bibliotech.max.fine"); // "20.0", from the defaultgetProperty looks first in the instance and, if it does not find the key, in the fallback, recursively. The file only needs to declare what changes.
It is clean, but it has two serious traps that must be known:
1. stringPropertyNames() does include the default values, but keySet() and size() do NOT.
configuration.size(); // 1: only what came from the file
configuration.stringPropertyNames().size(); // 3: it includes the defaultsIterating with keySet() skips the default values without warning. Always use stringPropertyNames().
2. store() does not save the default values. If you load with a fallback and save, the resulting file only has the explicit entries. That is correct —the file declares what changes— but it is surprising if you expected a complete dump.
Because of these two traps, and because the real hierarchy needs more than two levels, BiblioTech will implement its own Configuration class instead of using the chaining directly.
- The configuration hierarchy and its precedence
A professional application obtains its configuration from several sources, with a defined precedence:
flowchart TD
A["1. Default values<br/>in the code<br/>(lowest priority)"] --> B["2. Classpath file<br/>bibliotech-default.properties"]
B --> C["3. External file<br/>bibliotech.properties"]
C --> D["4. Environment variables<br/>BIBLIOTECH_LOAN_DAYS"]
D --> E["5. System properties<br/>-Dbibliotech.loan.days=20<br/>(highest priority)"]
style A fill:#e3f2fd
style E fill:#c8e6c9
Each level overrides the previous one. The precedence table, from lowest to highest:
| Level | Source | Who controls it | When it is used |
|---|---|---|---|
| 1 | Code constants | The developer | There is always a value: the application is never left without |
| 2 | Classpath file | The developer | The factory values, inside the .jar |
| 3 | External file | The administrator | The deployment configuration |
| 4 | Environment variables | The system or the container | Container and cloud deployments |
| 5 | -D on the command line |
Whoever runs it | Tests and one-off adjustments without touching anything |
Why this exact order:
- The code defaults come last in priority because they are the safety net: they guarantee the application starts even if there is no file at all. It is the graceful degradation of 06-07 applied to configuration.
- Environment variables sit above the file because in a container you cannot always mount a file, and they are the standard way of configuring in Docker and Kubernetes.
-Dbeats everything because it is the explicit, immediate way of saying "this time, this", without touching anything permanent. It is what somebody debugging a problem in production uses.
The naming convention between -D and environment variables:
| Property | Environment variable |
|---|---|
bibliotech.loan.days |
BIBLIOTECH_LOAN_DAYS |
bibliotech.daily.rate |
BIBLIOTECH_DAILY_RATE |
The transformation is mechanical: upper case and dots into underscores. It is the de facto convention in the ecosystem —Spring Boot uses it, which you will see in 11-02— and it must be respected so that nobody has to learn two names.
- BiblioTech's
Configuration class
Configuration classpackage com.nexussoftware.bibliotech.infrastructure;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Reader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Locale;
import java.util.Objects;
import java.util.Properties;
import java.util.TreeMap;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* BiblioTech configuration with a hierarchy of sources.
*
* Precedence, from lowest to highest:
* 1. code default values
* 2. bibliotech-default.properties from the classpath
* 3. external file bibliotech.properties
* 4. environment variables (BIBLIOTECH_XXX_YYY)
* 5. system properties (-Dbibliotech.xxx.yyy)
*
* GRACEFUL DEGRADATION (06-07): if there is no file at all, the application
* starts with the default values and logs it. Configuring badly must not
* prevent start-up; what MUST prevent it is an INVALID value, because
* computing fines with an unknown rate would produce incorrect charges.
*/
public final class Configuration {
private static final Logger LOG = Logger.getLogger(Configuration.class.getName());
private static final String DEFAULT_RESOURCE = "/bibliotech-default.properties";
private static final String EXTERNAL_FILE = "bibliotech.properties";
private static final String PREFIX = "bibliotech.";
// ---------- Keys ----------
public static final String LOAN_DAYS = "bibliotech.loan.days";
public static final String MAX_LOANS = "bibliotech.max.loans";
public static final String DAILY_RATE = "bibliotech.daily.rate";
public static final String MAX_FINE = "bibliotech.max.fine";
public static final String MINOR_THRESHOLD = "bibliotech.minor.threshold";
public static final String DATA_DIRECTORY = "bibliotech.data.directory";
public static final String DATA_CATALOG = "bibliotech.data.catalog";
public static final String CSV_SEPARATOR = "bibliotech.csv.separator";
/** The properties already resolved by the hierarchy. */
private final Properties resolved = new Properties();
/** Where each value came from. Useful for diagnosis. */
private final TreeMap<String, String> source = new TreeMap<>();
private static Configuration instance;
private Configuration() { }
/**
* Loads the configuration. Idempotent.
*
* @param filePath external file, or null for the default name
*/
public static synchronized Configuration load(Path filePath) {
if (instance != null) {
return instance;
}
Configuration c = new Configuration();
c.applyCodeDefaults();
c.applyClasspathResource();
c.applyExternalFile(filePath);
c.applyEnvironmentVariables();
c.applySystemProperties();
c.validate();
instance = c;
LOG.info(() -> "Configuration loaded: " + c.resolved.size() + " properties");
return instance;
}
public static synchronized Configuration get() {
if (instance == null) {
return load(null);
}
return instance;
}
/** For tests only: allows reloading between cases. */
static synchronized void reset() { instance = null; }
// ---------------------- THE FIVE LEVELS ----------------------
/**
* LEVEL 1: the code defaults.
*
* These are the values that have been hard-coded for seven modules. Now
* they are in one place and are only the LAST resort, not the only option.
*/
private void applyCodeDefaults() {
put(LOAN_DAYS, "15", "code default");
put(MAX_LOANS, "3", "code default");
put(DAILY_RATE, "0.25", "code default");
put(MAX_FINE, "20.0", "code default");
put(MINOR_THRESHOLD, "7", "code default");
put(DATA_DIRECTORY, "data", "code default");
put(DATA_CATALOG, "catalog.csv", "code default");
put(CSV_SEPARATOR, ",", "code default");
}
/** LEVEL 2: the factory file, inside the jar. */
private void applyClasspathResource() {
try (InputStream input = Configuration.class.getResourceAsStream(DEFAULT_RESOURCE)) {
if (input == null) {
LOG.fine(() -> "No " + DEFAULT_RESOURCE + " resource on the classpath");
return;
}
// The BRIDGE from 07-03 with an explicit charset
try (Reader reader = new InputStreamReader(input, StandardCharsets.UTF_8)) {
merge(read(reader), "classpath " + DEFAULT_RESOURCE);
}
} catch (IOException e) {
LOG.log(Level.WARNING, "Could not read " + DEFAULT_RESOURCE, e);
}
}
/**
* LEVEL 3: the external file.
*
* Its absence is NOT an error: the first run does not have it.
* Graceful degradation (06-07, 07-01).
*/
private void applyExternalFile(Path path) {
Path file = (path != null) ? path : Path.of(EXTERNAL_FILE);
if (Files.notExists(file)) {
LOG.info(() -> "There is no configuration file at "
+ file.toAbsolutePath()
+ "; the default values are used");
return;
}
try (BufferedReader reader = Files.newBufferedReader(
file, StandardCharsets.UTF_8)) { // 07-06
merge(read(reader), "file " + file.toAbsolutePath());
LOG.info(() -> "Configuration read from " + file.toAbsolutePath());
} catch (IOException e) {
// An UNREADABLE file IS a serious warning: somebody put it there
// expecting it to be applied, and it is not being applied.
LOG.log(Level.SEVERE, "Could not read " + file.toAbsolutePath()
+ "; carrying on with the previous values", e);
}
}
/**
* LEVEL 4: environment variables.
*
* BIBLIOTECH_LOAN_DAYS -> bibliotech.loan.days
*/
private void applyEnvironmentVariables() {
for (var entry : System.getenv().entrySet()) {
String variable = entry.getKey();
if (!variable.startsWith("BIBLIOTECH_")) {
continue;
}
String key = variable.toLowerCase(Locale.ROOT).replace('_', '.');
put(key, entry.getValue(), "environment variable " + variable);
}
}
/**
* LEVEL 5: system properties (-D). The highest priority.
*/
private void applySystemProperties() {
Properties system = System.getProperties();
for (String key : system.stringPropertyNames()) {
if (key.startsWith(PREFIX)) {
put(key, system.getProperty(key), "-D" + key);
}
}
}
private Properties read(Reader reader) throws IOException {
Properties p = new Properties();
p.load(reader);
return p;
}
private void merge(Properties sourceProps, String description) {
// stringPropertyNames, NOT keySet: it includes the defaults (section 12)
for (String key : sourceProps.stringPropertyNames()) {
put(key, sourceProps.getProperty(key), description);
}
}
private void put(String key, String value, String sourceDescription) {
if (value == null) {
return;
}
// trim: trailing spaces in the value ARE preserved in .properties
resolved.setProperty(key, value.trim());
source.put(key, sourceDescription);
}
// ---------------------- VALIDATION ----------------------
/**
* Checks that the values are coherent.
*
* The distinction from 06-07: a MISSING file degrades (the defaults are
* used); an INVALID value ABORTS, because computing fines with an unknown
* rate would produce incorrect charges, and that is worse than not starting.
*/
private void validate() {
int days = getInt(LOAN_DAYS, 15);
if (days < 1 || days > 365) {
throw new InvalidConfigurationException(LOAN_DAYS,
String.valueOf(days), "it must be between 1 and 365");
}
int maximum = getInt(MAX_LOANS, 3);
if (maximum < 1 || maximum > 50) {
throw new InvalidConfigurationException(MAX_LOANS,
String.valueOf(maximum), "it must be between 1 and 50");
}
double rate = getDouble(DAILY_RATE, 0.25);
if (rate < 0 || rate > 100) {
throw new InvalidConfigurationException(DAILY_RATE,
String.valueOf(rate), "it must be between 0 and 100 EUR/day");
}
double cap = getDouble(MAX_FINE, 20.0);
if (cap < 0) {
throw new InvalidConfigurationException(MAX_FINE,
String.valueOf(cap), "it cannot be negative");
}
if (cap < rate) {
throw new InvalidConfigurationException(MAX_FINE,
String.valueOf(cap),
"it cannot be lower than the daily rate (" + rate + ")");
}
int threshold = getInt(MINOR_THRESHOLD, 7);
if (threshold < 0 || threshold > days) {
throw new InvalidConfigurationException(MINOR_THRESHOLD,
String.valueOf(threshold),
"it must be between 0 and the loan term (" + days + ")");
}
}
// ---------------------- TYPED ACCESS ----------------------
public String getText(String key, String defaultValue) {
return resolved.getProperty(key, defaultValue);
}
/**
* An integer with a default value.
*
* A non-numeric value does NOT blow up the application: a warning is
* issued and the default is used. The later validation checks the range.
*/
public int getInt(String key, int defaultValue) {
String value = resolved.getProperty(key);
if (value == null || value.isBlank()) {
return defaultValue;
}
try {
return Integer.parseInt(value.trim());
} catch (NumberFormatException e) {
LOG.warning(() -> String.format(
"The property %s has the non-numeric value '%s' (source: %s); "
+ "%d is used", key, value, source.get(key), defaultValue));
return defaultValue;
}
}
/** A decimal. It accepts a comma or a dot when reading, always writes a dot (section 7). */
public double getDouble(String key, double defaultValue) {
String value = resolved.getProperty(key);
if (value == null || value.isBlank()) {
return defaultValue;
}
try {
return Double.parseDouble(value.trim().replace(',', '.'));
} catch (NumberFormatException e) {
LOG.warning(() -> String.format(
"The property %s has the non-numeric value '%s' (source: %s); "
+ "%s is used", key, value, source.get(key), defaultValue));
return defaultValue;
}
}
public boolean getBoolean(String key, boolean defaultValue) {
String value = resolved.getProperty(key);
if (value == null || value.isBlank()) {
return defaultValue;
}
String clean = value.trim().toLowerCase(Locale.ROOT);
return switch (clean) { // switch expression (02-03)
case "true", "yes", "y", "1", "on" -> true;
case "false", "no", "n", "0", "off" -> false;
default -> {
LOG.warning(() -> "Unrecognised boolean value in " + key
+ ": '" + value + "'; " + defaultValue + " is used");
yield defaultValue;
}
};
}
public char getChar(String key, char defaultValue) {
String value = resolved.getProperty(key);
return (value == null || value.isEmpty()) ? defaultValue : value.charAt(0);
}
public Path getPath(String key, String defaultValue) {
return Path.of(getText(key, defaultValue));
}
/**
* Diagnostic dump: each property with its value and its SOURCE.
*
* Knowing WHERE each value comes from is what lets you resolve in a
* minute the classic "I changed the file and it takes no notice": there
* is nearly always an environment variable or a -D overriding it.
*/
public String dump() {
StringBuilder sb = new StringBuilder();
sb.append("=== EFFECTIVE CONFIGURATION ===\n");
for (String key : new TreeMap<>(source).keySet()) {
sb.append(String.format(" %-42s = %-16s [%s]%n",
key, resolved.getProperty(key), source.get(key)));
}
return sb.toString();
}
/** Invalid configuration: the application must NOT start like this. */
public static class InvalidConfigurationException extends RuntimeException {
private static final long serialVersionUID = 1L; // 07-05
private final String key;
private final String value;
public InvalidConfigurationException(String key, String value, String reason) {
super(String.format(
"Invalid configuration: %s = '%s'. %s. "
+ "Correct the configuration file and start again.",
key, value, reason));
this.key = key;
this.value = value;
}
public String getKey() { return key; }
public String getValue() { return value; }
}
}
- The four constants stop being constants
The moment promised since module 1. This is how the four constants stood:
// BEFORE: hard-coded, scattered across three classes
public class Loan {
public static final int LOAN_DAYS = 15; // 06-03
}
public abstract class Material {
public static final double MAX_FINE = 20.0; // 04-02
public static final int MINOR_THRESHOLD = 7; // 04-02
}
public class Book extends Material {
public static final double BOOK_DAILY_RATE = 0.25; // 04-02
}
public class Employee {
public static final int MAX_CONCURRENT_LOANS = 3; // 03-03
}Changing the rate meant editing the code, recompiling, packaging and deploying. Now:
package com.nexussoftware.bibliotech.domain;
import com.nexussoftware.bibliotech.infrastructure.Configuration;
/**
* BiblioTech business rules, read from the configuration.
*
* It replaces the constants scattered across the domain since module 3.
* The values are read ONCE at start-up and stay fixed for the run:
* changing them halfway would mean two loans on the same day being
* computed with different rules, which is unacceptable in a system that
* charges money.
*
* To reload the configuration the application must be restarted. It is a
* DELIBERATE decision, not a limitation.
*/
public final class BusinessRules {
private static final Configuration CONFIG = Configuration.get();
/** Loan term in days. Before: Loan.LOAN_DAYS = 15 */
public static final int LOAN_DAYS =
CONFIG.getInt(Configuration.LOAN_DAYS, 15);
/** Concurrent loans per employee. Before: Employee.MAX_CONCURRENT_LOANS = 3 */
public static final int MAX_CONCURRENT_LOANS =
CONFIG.getInt(Configuration.MAX_LOANS, 3);
/** EUR per day late. Before: Book.BOOK_DAILY_RATE = 0.25 */
public static final double DAILY_RATE =
CONFIG.getDouble(Configuration.DAILY_RATE, 0.25);
/** Fine cap. Before: Material.MAX_FINE = 20.0 */
public static final double MAX_FINE =
CONFIG.getDouble(Configuration.MAX_FINE, 20.0);
/** Days until lateness stops being minor. Before: Material.MINOR_THRESHOLD = 7 */
public static final int MINOR_THRESHOLD =
CONFIG.getInt(Configuration.MINOR_THRESHOLD, 7);
private BusinessRules() { }
/** A summary for start-up, so there is a record of which rules are in force. */
public static String summary() {
return String.format(
"Rules: %d loan days, maximum %d concurrent, "
+ "%.2f EUR/day, cap %.2f EUR, minor threshold %d days",
LOAN_DAYS, MAX_CONCURRENT_LOANS,
DAILY_RATE, MAX_FINE, MINOR_THRESHOLD);
}
}And the domain now uses them:
public abstract class Material implements Lendable, Notifiable {
// Before: literal constants. Now: read from the configuration.
// The Template Method of 04-02 does not change one line: only where
// the numbers come from changes.
public final double calculateFine(int elapsedDays) {
int daysLate = calculateDaysLate(elapsedDays);
double gross = daysLate * getDailyRate();
return Math.min(gross, BusinessRules.MAX_FINE);
}
public final Severity classifySeverity(int elapsedDays) {
int daysLate = calculateDaysLate(elapsedDays);
if (daysLate == 0) { return Severity.ON_TIME; }
if (daysLate <= BusinessRules.MINOR_THRESHOLD) { return Severity.MINOR; }
return Severity.SEVERE;
}
}The complete start-up:
package com.nexussoftware.bibliotech.presentation;
import java.io.IOException;
import java.nio.file.Path;
import java.util.logging.Level;
import java.util.logging.Logger;
import com.nexussoftware.bibliotech.domain.BusinessRules;
import com.nexussoftware.bibliotech.infrastructure.LogConfiguration;
import com.nexussoftware.bibliotech.infrastructure.Configuration;
import com.nexussoftware.bibliotech.service.Catalog;
import com.nexussoftware.bibliotech.service.CsvCatalogExporter;
public class BiblioTechApp {
private static final Logger LOG = Logger.getLogger(BiblioTechApp.class.getName());
public static void main(String[] args) {
LogConfiguration.initialise(); // 06-07
Configuration config;
try {
Path path = (args.length > 0) ? Path.of(args[0]) : null;
config = Configuration.load(path);
} catch (Configuration.InvalidConfigurationException e) {
// The main error boundary (06-07). An invalid value ABORTS:
// starting with an absurd rate would produce incorrect charges.
System.err.println("CONFIGURATION ERROR");
System.err.println(" " + e.getMessage());
LOG.log(Level.SEVERE, "Start-up aborted by invalid configuration", e);
System.exit(2);
return;
}
LOG.config(config::dump);
System.out.println("BiblioTech - Nexus Software");
System.out.println(BusinessRules.summary());
try {
Path directory = config.getPath(Configuration.DATA_DIRECTORY, "data");
CsvCatalogExporter persistence = new CsvCatalogExporter(directory);
Catalog catalog = new Catalog();
var result = persistence.importCatalog(catalog);
System.out.print(result.summary());
// Save on exit (07-02)
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
try {
int n = persistence.exportCatalog(catalog);
LOG.info("Catalogue saved on exit: " + n + " materials");
} catch (IOException e) {
LOG.log(Level.SEVERE, "COULD NOT SAVE THE CATALOGUE", e);
}
}, "final-save"));
new BiblioTechMenu(catalog, persistence).start();
} catch (IOException e) {
System.err.println("Could not access the data store: " + e.getMessage());
LOG.log(Level.SEVERE, "I/O failure at start-up", e);
System.exit(3);
}
}
}The demonstration of the change, without recompiling anything:
# 1. No configuration file: default values
$ java -cp bibliotech.jar com.nexussoftware.bibliotech.presentation.BiblioTechApp
BiblioTech - Nexus Software
Rules: 15 loan days, maximum 3 concurrent, 0.25 EUR/day, cap 20.00 EUR, minor threshold 7 days
# 2. With a configuration file
$ cat bibliotech.properties
bibliotech.loan.days = 21
bibliotech.daily.rate = 0.50
$ java -cp bibliotech.jar com.nexussoftware.bibliotech.presentation.BiblioTechApp
BiblioTech - Nexus Software
Rules: 21 loan days, maximum 3 concurrent, 0.50 EUR/day, cap 20.00 EUR, minor threshold 7 days
# 3. Environment variable: it overrides the file
$ BIBLIOTECH_MAX_FINE=50.0 java -cp bibliotech.jar ...
Rules: 21 loan days, maximum 3 concurrent, 0.50 EUR/day, cap 50.00 EUR, minor threshold 7 days
# 4. -D: it overrides everything
$ java -Dbibliotech.loan.days=7 -cp bibliotech.jar ...
Rules: 7 loan days, maximum 3 concurrent, 0.50 EUR/day, cap 50.00 EUR, minor threshold 7 days
# 5. An invalid value: the application does NOT start
$ java -Dbibliotech.max.fine=-5 -cp bibliotech.jar ...
CONFIGURATION ERROR
Invalid configuration: bibliotech.max.fine = '-5.0'. it cannot be negative.
Correct the configuration file and start again.
$ echo $?
2Case 5 is the one that shows the design is right. A missing file degrades; an invalid value aborts with a distinguishable exit code. It is exactly the distinction of 06-07: degrade when the reduced service is still correct; abort when carrying on would produce incorrect results. A library with no catalogue is correct; a negative maximum fine produces absurd charges.
And the diagnostic dump, which solves the classic "I changed the file and it takes no notice":
=== EFFECTIVE CONFIGURATION ===
bibliotech.csv.separator = , [code default]
bibliotech.daily.rate = 0.50 [file /home/marta/bibliotech.properties]
bibliotech.data.catalog = catalog.csv [code default]
bibliotech.data.directory = data [file /home/marta/bibliotech.properties]
bibliotech.loan.days = 7 [-Dbibliotech.loan.days]
bibliotech.max.fine = 50.0 [environment variable BIBLIOTECH_MAX_FINE]
bibliotech.max.loans = 3 [code default]
bibliotech.minor.threshold = 7 [code default]Every value with its source. It is one of those small features that save hours of bewilderment.
- Never store credentials in the repository
A warning that has to be given as clearly as possible.
NEVER put passwords, API keys, tokens or connection strings with credentials in a properties file that is under version control.
Why, beyond the obvious:
- Git history is permanent. Deleting the password in a later commit does not remove it: it is still in the history and anybody with access to the repository can recover it. Rewriting history is painful and not always possible.
- Repositories are cloned and shared. Everybody who has ever had access has a complete copy of the history on their laptop.
- Repositories are made public by mistake. It happens constantly. There are bots permanently crawling public repositories looking for keys, and the time between publishing a key and it being used is usually measured in minutes.
- Backups multiply the copies of the file everywhere.
What to do instead:
| Approach | How | When |
|---|---|---|
| Environment variables | BIBLIOTECH_DB_PASSWORD |
Containers, cloud. The most usual |
| File outside the repository | /etc/bibliotech/secrets.properties, with 600 permissions |
Your own servers |
| Secrets manager | Vault, AWS Secrets Manager, Azure Key Vault | Serious production |
| Ignored local file | bibliotech-local.properties in .gitignore |
Development |
The pattern BiblioTech uses:
# bibliotech.properties: this DOES go in the repository.
# Functional configuration, with no secrets whatsoever.
bibliotech.loan.days = 15
bibliotech.daily.rate = 0.25
# Secrets do NOT go here. They are read from environment variables:
# BIBLIOTECH_DB_USER
# BIBLIOTECH_DB_PASSWORD
# In development, put them in bibliotech-local.properties, which is in .gitignore./**
* Reads a secret. NEVER from the repository file, and NEVER with a default
* value: a missing secret must fail, not work halfway.
*/
public String getSecret(String environmentVariable) {
String value = System.getenv(environmentVariable);
if (value == null || value.isBlank()) {
throw new IllegalStateException(String.format(
"The environment variable %s is missing. Secrets are not read "
+ "from the configuration file. See the deployment "
+ "documentation.", environmentVariable));
}
// And NEVER log it (06-07): not the value, not part of it, not its length.
LOG.config(() -> "Secret " + environmentVariable + " loaded correctly");
return value;
}Notice the two decisions: no default value —a missing secret must fail loudly, not leave the application running insecurely— and not logging the value, applying what 06-07 said about what must never appear in the log.
And the formal warning, as in 07-05: this is an introduction, not a security guide. Secrets management in a real system is defined and reviewed by your organisation's security officer. Application security is covered, as far as this course goes, in 12-07.
- Other formats: XML, JSON and YAML
To close the map of interchange formats:
| Format | Structure | Readable | Types | Comments | In Java |
|---|---|---|---|---|---|
| Properties | Flat key-value | Yes | No: all text | Yes (#) |
java.util.Properties |
| CSV | Tabular | Yes | No: all text | Not standard | By hand or a library |
| XML | Hierarchical with attributes | Fairly | With a schema | Yes | JAXP, JAXB, in the JDK |
| JSON | Hierarchical | Yes | Yes: number, text, boolean, null | No | Jackson, Gson (11-07) |
| YAML | Hierarchical, by indentation | Very | Yes | Yes | SnakeYAML |
XML is in the JDK and needs no dependencies. It is verbose and has fallen out of use for interchange, but it lives on in enterprise configuration, in SOAP and in document formats. It has a well-known security risk, external entities (XXE), which demands explicitly disabling entity processing when parsing foreign data.
JSON is the current standard for web APIs. It has types —unlike CSV and Properties—, it is hierarchical and every language understands it. Its great absence is comments, which makes it awkward as a configuration format. It is covered in 11-07, with Jackson.
YAML is the fashionable configuration format: readable, hierarchical, with comments. Its great defect is sensitivity to indentation, which produces subtle errors, and its specification is surprisingly complex.
What to choose:
| You need | Format |
|---|---|
| Flat configuration for an application | Properties |
| Tabular data for a spreadsheet | CSV |
| A web API | JSON |
| Complex hierarchical configuration | YAML |
| Documents with a schema and strict validation | XML |
| Maximum performance and Java only | Binary (07-03) |
Common Mistakes and Tips
- Splitting a CSV with
split(","). It fails with separators inside fields, escaped quotes and line breaks. It is the mistake that lost the titles of BiblioTech's books. - Forgetting the
-1insplit."a,b,".split(",")gives 2 elements, not 3. It discards the trailing empty fields. - Not stripping the BOM. The first header column matches nothing and the file looks perfect in the editor. One of the most baffling failures there is.
- Trying to parse CSV with a regular expression. It works for the simple case, is unreadable, and fails with everything else. A two-state machine is forty lines and does work.
- Escaping quotes with a backslash. CSV doubles the quote:
"", not\". - Assuming a record is a line. A field with a line break inside spans several physical lines.
- Confusing
nullwith the empty string in CSV. The format does not distinguish them. If your domain does, use a documented sentinel or change format. - Using a comma as the separator and expecting it to open properly in a European Excel. It opens as a single column.
;for human consumption in Europe,,for interchange. - Writing decimals without
Locale.ROOT. On a Spanish machine,String.format("%.2f", 0.25)gives0,25, which with a comma separator makes the file ambiguous and unreadable back. - Not doing the round-trip test. Writing, reading and comparing with the original is the only serious way of validating a format. It is what was missing from the
sanitise()of 07-02. - Using
props.get()instead ofgetProperty().getcomes fromHashtableand bypasses the default-value mechanism. - Using
keySet()instead ofstringPropertyNames(). The first does not include the chained default values. - Not calling
trim()on the values read. Trailing spaces are preserved in.properties, and they are invisible. - Unescaped backslashes in Windows paths.
C:\datareads\das an escape. Use/or double the backslash. - Loading
.propertieswithout an explicit charset. Use theReaderoverloads, not theInputStreamones. - Using
store()on a carefully commented file.Propertiespreserves neither order nor comments: you lose them all. getPropertywith no default value. It returnsnull, which ends up in aparseIntwith the messagenull, which does not say which key was missing.- Not validating the values read. A rate of −5 EUR/day sails through and produces negative fines. Validating is compulsory.
- Aborting because the configuration file is missing. It is the opposite of correct: absence degrades to the defaults; what aborts is an invalid value.
- Storing passwords in the repository file. Git history is permanent and bots crawl public repositories constantly.
- Logging the value of a secret. Not the value, not part of it, not its length.
- Tip: always do the round-trip test with the difficult cases. Commas, quotes, line breaks, empty fields and spaces at the ends. If they come back identical, the format is correct.
- Tip: log the source of every configuration value. The dump with
[file],[environment variable],[-D]solves in a minute the classic "I changed the file and it takes no notice". - Tip: tolerant when reading, strict when writing. Accept a comma or a dot decimal when reading; always write a dot.
- Tip: implement it once to understand it, use the library in production. It applies to CSV and to almost everything else.
Exercises
Exercise 1: CSV test battery
Write FullCsvTest to validate CsvReader and CsvWriter with a systematic battery:
- A table of at least twelve cases: a normal field, one with a separator, one with quotes, one with a line break, an empty one, one with only spaces, one with spaces at the ends, one with accents and diacritics, one with the alternative separator, one with quotes at the start, one with quotes in the middle without the field being quoted, and a very long field.
- For each case: write, read and compare with the original, reporting OK or FAIL.
- A final count of the cases passed.
- Failure tests: a file with an unclosed quote must throw
CsvFormatExceptionindicating the line. - A BOM test: create the content starting with
\uFEFFand check that the first column is read cleanly.
Exercise 2: format converter
Write FormatConverter to convert between the formats you know:
csvToProperties(Path csv, Path properties): takes a two-columnkey,valueCSV and generates a.properties.propertiesToCsv(Path properties, Path csv): the inverse, with the keys sorted alphabetically.csvToCsv(Path source, Path target, char sourceSep, char targetSep): changes the separator respecting the escaping, so as to go from the international format to the European Excel format.- All the operations with atomic writing (07-06) and an explicit charset.
- A
maindemonstrating the three conversions and including in the data a value with a comma, one with a semicolon and one with quotes, to check that nothing is lost.
Exercise 3: configuration with reloading and validation
Extend BiblioTech's Configuration:
reload()re-reading all the sources and returning a report of which properties have changed, with their previous and new values.- A notification mechanism: a functional interface
ConfigurationListenerwithchanged(String key, String previous, String current), and a register of listeners notified on every reload (picking upReturnListenerfrom 04-06). exportTemplate(Path target)generating a commented.propertieswith all the known keys, their current value, their source and a description, so that an administrator has a complete template.- Flag the properties that do not support hot reloading —the business rules— and warn if they change, explaining that a restart is needed.
- A
mainthat loads, exports the template, modifies the file, reloads and shows the change report.
Solutions
Solution 1
package com.nexussoftware.bibliotech.tests;
import java.io.IOException;
import java.io.StringReader;
import java.io.StringWriter;
import java.util.List;
import com.nexussoftware.bibliotech.infrastructure.CsvWriter;
import com.nexussoftware.bibliotech.infrastructure.CsvReader;
/**
* CSV round-trip test battery.
*
* The round-trip test —write, read and compare with the original— is the
* only serious way of validating a format: if something comes back
* different, there is data loss.
*
* It works IN MEMORY with StringWriter and StringReader (07-03): no files,
* no clean-up, no dependency on the environment.
*/
public class FullCsvTest {
private static int passed = 0;
private static int failed = 0;
public static void main(String[] args) throws IOException {
System.out.println("=== ROUND TRIP WITH SEPARATOR ',' ===");
char sep = ',';
check(sep, "1. Normal field", "Effective Java", "Bloch", "2018");
check(sep, "2. With a separator inside", "Java: the language, the machine", "Bloch", "2018");
check(sep, "3. With quotes", "The book \"Refactoring\"", "Fowler", "1999");
check(sep, "4. With a line break", "Title with\na line break", "Author", "2020");
check(sep, "5. Empty field in the middle", "Title", "", "2021");
check(sep, "6. Empty field at the end", "Title", "Author", "");
check(sep, "7. All empty", "", "", "");
check(sep, "8. Only spaces", " ", "Author", "2022");
check(sep, "9. Spaces at the ends", " with spaces ", "Author", "2022");
check(sep, "10. Accents and diacritics", "Naïve Set Theory", "Zoë Blackwood", "1994");
check(sep, "11. Semicolon inside", "field;with;semicolons", "Author", "2023");
check(sep, "12. Quotes in the middle", "The \"best\" Java book", "Author", "2024");
check(sep, "13. A single quote", "Loose quote \" here", "Author", "2024");
check(sep, "14. Very long field", "x".repeat(5000), "Author", "2024");
check(sep, "15. Separator and quotes", "a,b\"c,d", "Author", "2024");
System.out.println();
System.out.println("=== ROUND TRIP WITH SEPARATOR ';' ===");
char sep2 = ';';
check(sep2, "16. Comma inside with sep ;", "Java: the language, the machine", "Bloch", "2018");
check(sep2, "17. Semicolon inside", "field;with;semicolons", "Author", "2023");
// ---- Controlled failure: unclosed quote ----
System.out.println();
System.out.println("=== CONTROLLED FAILURES ===");
checkUnclosedQuotes();
// ---- BOM ----
System.out.println();
System.out.println("=== BOM MARK ===");
checkBom();
System.out.println();
System.out.printf("=== RESULT: %d passed, %d failed ===%n",
passed, failed);
if (failed > 0) {
System.out.println("THERE IS DATA LOSS. Review the escaping.");
}
}
/** Writes the fields, reads them back and compares with the original. */
private static void check(char separator, String name, String... fields)
throws IOException {
List<String> originals = List.of(fields);
StringWriter memory = new StringWriter();
try (CsvWriter writer = new CsvWriter(memory, separator, "\n")) {
writer.writeRecord(originals);
}
String generated = memory.toString();
List<String> read;
try (CsvReader reader = new CsvReader(new StringReader(generated), separator)) {
read = reader.next();
}
boolean ok = originals.equals(read);
if (ok) { passed++; } else { failed++; }
System.out.printf(" %-30s %s%n", name, ok ? "OK" : "FAIL");
if (!ok) {
System.out.println(" original: " + originals);
System.out.println(" read : " + read);
System.out.println(" csv : " + generated.replace("\n", "\\n"));
}
}
/** A file with an unclosed quote must fail, indicating the line. */
private static void checkUnclosedQuotes() {
String bad = "title,author\n\"Unclosed,Bloch";
try (CsvReader reader = new CsvReader(new StringReader(bad))) {
reader.next(); // the header, correct
reader.next(); // this one must fail
System.out.println(" Unclosed quote FAIL (it should throw)");
failed++;
} catch (CsvReader.CsvFormatException e) {
System.out.println(" Unclosed quote OK");
System.out.println(" " + e.getMessage());
passed++;
} catch (IOException e) {
System.out.println(" Unclosed quote FAIL (unexpected exception)");
failed++;
}
}
/**
* The BOM mark Excel writes at the start of UTF-8 files.
*
* Without handling it, the first column reads as "\uFEFFtype" and does NOT
* match "type", even though the file looks perfect in any editor.
*/
private static void checkBom() throws IOException {
String withBom = "\uFEFFtype,reference,title\nBOOK,978-0000000001,Effective Java";
try (CsvReader reader = new CsvReader(new StringReader(withBom))) {
List<String> header = reader.next();
boolean ok = "type".equals(header.get(0));
if (ok) { passed++; } else { failed++; }
System.out.printf(" BOM removed %s%n", ok ? "OK" : "FAIL");
System.out.println(" first column: '" + header.get(0)
+ "' (length " + header.get(0).length() + ")");
if (!ok) {
System.out.println(" The BOM is still there: the comparison with 'type' fails");
}
}
}
}Output:
=== ROUND TRIP WITH SEPARATOR ',' ===
1. Normal field OK
2. With a separator inside OK
3. With quotes OK
4. With a line break OK
5. Empty field in the middle OK
6. Empty field at the end OK
7. All empty OK
8. Only spaces OK
9. Spaces at the ends OK
10. Accents and diacritics OK
11. Semicolon inside OK
12. Quotes in the middle OK
13. A single quote OK
14. Very long field OK
15. Separator and quotes OK
=== ROUND TRIP WITH SEPARATOR ';' ===
16. Comma inside with sep ; OK
17. Semicolon inside OK
=== CONTROLLED FAILURES ===
Unclosed quote OK
Line 2: The file ends with an unclosed quote
=== BOM MARK ===
BOM removed OK
first column: 'type' (length 4)
=== RESULT: 19 passed, 0 failed ===The three teaching points:
- Cases 8 and 9 are the ones most often forgotten. A field of only spaces and one with spaces at the ends: if the writer does not quote them, the reader trims them on reading and the data comes back different. It is a silent loss.
- Case 15 combines a separator and quotes in the same field, which is where most of the home-made implementations found on the internet fail.
- Everything is tested in memory.
StringWriterandStringReaderfrom 07-03 allow a complete battery without creating a single file, with no clean-up afterwards and no dependency on the file system. It is exactly the design lesson of 07-03: accept the most general abstraction that serves you.
Solution 2
package com.nexussoftware.bibliotech.util;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.ArrayList;
import java.util.List;
import java.util.Properties;
import java.util.TreeSet;
import java.util.logging.Logger;
import com.nexussoftware.bibliotech.infrastructure.CsvWriter;
import com.nexussoftware.bibliotech.infrastructure.CsvReader;
/**
* Conversions between CSV and Properties, and between CSV dialects.
*
* All the writes are ATOMIC (07-06): if the conversion fails halfway, the
* previous target file is left untouched.
*/
public class FormatConverter {
private static final Logger LOG = Logger.getLogger(FormatConverter.class.getName());
private static final java.nio.charset.Charset CHARSET = StandardCharsets.UTF_8;
// ---------------------- CSV -> PROPERTIES ----------------------
/**
* Converts a two-column 'key,value' CSV into a .properties.
*
* NOTE: Properties escapes the special characters on its own, so a value
* with '=' or ':' is stored escaped and comes back correct.
*/
public int csvToProperties(Path csv, Path target, char separator) throws IOException {
Properties props = new Properties();
int converted = 0;
try (BufferedReader reader = Files.newBufferedReader(csv, CHARSET);
CsvReader input = new CsvReader(reader, separator)) {
List<String> record = input.next(); // the header
if (record == null) {
throw new IOException("The file " + csv + " is empty");
}
while ((record = input.next()) != null) {
if (record.size() < 2) {
LOG.warning(() -> "Record with fewer than 2 fields; ignored");
continue;
}
String key = record.get(0).trim();
if (key.isEmpty()) {
continue;
}
props.setProperty(key, record.get(1));
converted++;
}
}
writePropertiesAtomically(props, target,
"Generated from " + csv.getFileName());
final int total = converted;
LOG.info(() -> String.format("CSV -> Properties: %d keys in %s",
total, target));
return converted;
}
// ---------------------- PROPERTIES -> CSV ----------------------
/** Converts a .properties into CSV, with the keys sorted. */
public int propertiesToCsv(Path properties, Path target, char separator)
throws IOException {
Properties props = new Properties();
try (BufferedReader reader = Files.newBufferedReader(properties, CHARSET)) {
props.load(reader);
}
// stringPropertyNames, not keySet (section 12). TreeSet to sort.
TreeSet<String> keys = new TreeSet<>(props.stringPropertyNames());
writeAtomically(target, output -> {
try (CsvWriter csv = new CsvWriter(output, separator, "\n")) {
csv.writeHeader("key", "value");
for (String key : keys) {
csv.writeRecord(key, props.getProperty(key));
}
}
});
LOG.info(() -> String.format("Properties -> CSV: %d keys in %s",
keys.size(), target));
return keys.size();
}
// ---------------------- CSV -> CSV ----------------------
/**
* Changes the separator of a CSV respecting the escaping.
*
* It is NOT a text substitution: a field that contained the NEW separator
* and not the old one now needs quotes, and vice versa. It can only be
* done properly by parsing and rewriting.
*/
public int csvToCsv(Path source, Path target, char sourceSep, char targetSep)
throws IOException {
List<List<String>> records = new ArrayList<>();
try (BufferedReader reader = Files.newBufferedReader(source, CHARSET);
CsvReader input = new CsvReader(reader, sourceSep)) {
List<String> record;
while ((record = input.next()) != null) {
records.add(record);
}
}
writeAtomically(target, output -> {
try (CsvWriter csv = new CsvWriter(output, targetSep, "\n")) {
for (List<String> r : records) {
csv.writeRecord(r);
}
}
});
LOG.info(() -> String.format("CSV '%c' -> CSV '%c': %d records in %s",
sourceSep, targetSep, records.size(), target));
return records.size();
}
// ---------------------- ATOMIC WRITING ----------------------
@FunctionalInterface
private interface Writing {
void writeTo(BufferedWriter output) throws IOException;
}
private void writeAtomically(Path target, Writing content) throws IOException {
Files.createDirectories(target.toAbsolutePath().getParent());
Path temp = target.resolveSibling(target.getFileName() + ".tmp");
boolean completed = false;
try {
try (BufferedWriter output = Files.newBufferedWriter(temp, CHARSET,
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING,
StandardOpenOption.WRITE)) {
content.writeTo(output);
}
Files.move(temp, target,
StandardCopyOption.REPLACE_EXISTING,
StandardCopyOption.ATOMIC_MOVE);
completed = true;
} catch (AtomicMoveNotSupportedException e) {
Files.move(temp, target, StandardCopyOption.REPLACE_EXISTING);
completed = true;
} finally {
if (!completed) {
Files.deleteIfExists(temp); // compensation (06-05)
}
}
}
private void writePropertiesAtomically(Properties props, Path target, String comment)
throws IOException {
writeAtomically(target, output -> props.store(output, comment));
}
// ---------------------- DEMONSTRATION ----------------------
public static void main(String[] args) throws IOException {
Path dir = Path.of("data/conversions");
Files.createDirectories(dir);
FormatConverter converter = new FormatConverter();
// 1. Starting CSV with the difficult cases
Path originalCsv = dir.resolve("configuration.csv");
Files.writeString(originalCsv, String.join("\n",
"key,value",
"bibliotech.loan.days,15",
"bibliotech.daily.rate,0.25",
"bibliotech.message.welcome,\"Welcome to BiblioTech, from Nexus Software\"",
"bibliotech.separators,\"semi;colon;list\"",
"bibliotech.quote,\"The book \"\"Refactoring\"\" by Fowler\"",
""), CHARSET);
System.out.println("=== ORIGINAL CSV ===");
System.out.println(Files.readString(originalCsv, CHARSET));
// 2. CSV -> Properties
Path props = dir.resolve("configuration.properties");
int n1 = converter.csvToProperties(originalCsv, props, ',');
System.out.println("=== PROPERTIES GENERATED (" + n1 + " keys) ===");
System.out.println(Files.readString(props, CHARSET));
// 3. Properties -> CSV, with separator ';' for a European Excel
Path excelCsv = dir.resolve("configuration-excel.csv");
int n2 = converter.propertiesToCsv(props, excelCsv, ';');
System.out.println("=== CSV FOR EUROPEAN EXCEL (" + n2 + " keys) ===");
System.out.println(Files.readString(excelCsv, CHARSET));
// 4. CSV ',' -> CSV ';' directly
Path semicolonCsv = dir.resolve("configuration-semicolon.csv");
int n3 = converter.csvToCsv(originalCsv, semicolonCsv, ',', ';');
System.out.println("=== CSV WITH SEPARATOR ';' (" + n3 + " records) ===");
System.out.println(Files.readString(semicolonCsv, CHARSET));
// 5. Check: complete round trip
Path back = dir.resolve("roundtrip.csv");
converter.csvToCsv(semicolonCsv, back, ';', ',');
boolean identical = Files.readString(originalCsv, CHARSET).trim()
.equals(Files.readString(back, CHARSET).trim());
System.out.println("=== ROUND TRIP ',' -> ';' -> ',' ===");
System.out.println(" Identical to the original? " + identical);
}
}Output (a fragment):
=== CSV WITH SEPARATOR ';' (6 records) ===
key;value
bibliotech.loan.days;15
bibliotech.daily.rate;0.25
bibliotech.message.welcome;Welcome to BiblioTech, from Nexus Software
bibliotech.separators;"semi;colon;list"
bibliotech.quote;"The book ""Refactoring"" by Fowler"
=== ROUND TRIP ',' -> ';' -> ',' ===
Identical to the original? trueThe three points to see:
- The escaping changes when the separator changes, and that shows it is not a text substitution. The value
Welcome to BiblioTech, from Nexus Softwareloses its quotes when moving to separator;—it no longer needs them, because its comma is harmless— and the valuesemi;colon;listgains them. Areplace(',', ';')would have wrecked both. - The round trip returns the file identical. It is the proof that the two conversions are exact inverses.
Properties.storewrites with its own escapes, so a value with=or:is stored escaped and comes back correct. That is the reason for converting by parsing and not by copying text.
Solution 3
package com.nexussoftware.bibliotech.infrastructure;
import java.io.BufferedWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Properties;
import java.util.Set;
import java.util.TreeMap;
import java.util.logging.Logger;
/**
* BiblioTech configuration with reloading, change notification and
* generation of a commented template.
*
* It extends the class of section 14 with what is needed to operate the
* system without stopping it completely.
*/
public class ReloadableConfiguration {
private static final Logger LOG =
Logger.getLogger(ReloadableConfiguration.class.getName());
private static final java.nio.charset.Charset CHARSET = StandardCharsets.UTF_8;
/**
* Description of a known property.
*
* 'reloadable' tells what can be changed hot from what demands a restart.
* The BUSINESS RULES are not reloadable: changing the rate halfway
* through a run would mean two loans on the same day being computed
* with different rules.
*/
public record PropertyDescription(String key, String defaultValue,
String description, boolean reloadable) { }
private static final List<PropertyDescription> KNOWN = List.of(
new PropertyDescription("bibliotech.loan.days", "15",
"Loan term in days", false),
new PropertyDescription("bibliotech.max.loans", "3",
"Concurrent loans per employee", false),
new PropertyDescription("bibliotech.daily.rate", "0.25",
"EUR per day late", false),
new PropertyDescription("bibliotech.max.fine", "20.0",
"Fine cap per loan, in EUR", false),
new PropertyDescription("bibliotech.minor.threshold", "7",
"Days until lateness stops being minor", false),
new PropertyDescription("bibliotech.data.directory", "data",
"Data directory", true),
new PropertyDescription("bibliotech.data.catalog", "catalog.csv",
"Name of the catalogue file", true),
new PropertyDescription("bibliotech.csv.separator", ",",
"CSV field separator: ',' or ';' for a European Excel", true),
new PropertyDescription("bibliotech.log.level", "INFO",
"Logging level: SEVERE, WARNING, INFO, CONFIG, FINE", true));
/** Configuration change listener (functional interface, 04-06). */
@FunctionalInterface
public interface ConfigurationListener {
void changed(String key, String previous, String current);
}
/** A change detected on reloading. */
public record Change(String key, String previous, String current, boolean reloadable) {
public String line() {
return String.format("%-42s %-14s -> %-14s %s",
key, previous == null ? "(no value)" : previous, current,
reloadable ? "" : " [REQUIRES RESTART]");
}
}
private final Path externalFile;
private final Map<String, String> values = new TreeMap<>();
private final Map<String, String> source = new TreeMap<>();
private final List<ConfigurationListener> listeners = new ArrayList<>();
public ReloadableConfiguration(Path externalFile) {
this.externalFile = Objects.requireNonNull(externalFile);
loadAll();
}
public void addListener(ConfigurationListener listener) {
listeners.add(Objects.requireNonNull(listener, "The listener cannot be null"));
}
// ---------------------- LOADING ----------------------
private void loadAll() {
values.clear();
source.clear();
// 1. Code defaults
for (PropertyDescription d : KNOWN) {
values.put(d.key(), d.defaultValue());
source.put(d.key(), "code default");
}
// 2. External file
if (Files.exists(externalFile)) {
Properties props = new Properties();
try (var reader = Files.newBufferedReader(externalFile, CHARSET)) {
props.load(reader);
for (String key : props.stringPropertyNames()) { // not keySet
values.put(key, props.getProperty(key).trim());
source.put(key, "file " + externalFile.getFileName());
}
} catch (IOException e) {
LOG.warning(() -> "Could not read " + externalFile + ": " + e.getMessage());
}
}
// 3. Environment variables
for (var e : System.getenv().entrySet()) {
if (e.getKey().startsWith("BIBLIOTECH_")) {
String key = e.getKey().toLowerCase(java.util.Locale.ROOT).replace('_', '.');
values.put(key, e.getValue().trim());
source.put(key, "environment " + e.getKey());
}
}
// 4. System properties
for (String key : System.getProperties().stringPropertyNames()) {
if (key.startsWith("bibliotech.")) {
values.put(key, System.getProperty(key).trim());
source.put(key, "-D" + key);
}
}
}
// ---------------------- RELOADING ----------------------
/**
* Re-reads all the sources and returns the changes detected.
*
* It notifies the listeners of each change, and WARNS about the ones
* needing a restart to take effect.
*/
public List<Change> reload() {
Map<String, String> previous = new LinkedHashMap<>(values);
loadAll();
List<Change> changes = new ArrayList<>();
Set<String> all = new java.util.TreeSet<>(previous.keySet());
all.addAll(values.keySet());
for (String key : all) {
String before = previous.get(key);
String now = values.get(key);
if (Objects.equals(before, now)) {
continue;
}
boolean reloadable = isReloadable(key);
changes.add(new Change(key, before, now, reloadable));
// Notify. A listener that fails must NOT break the reload.
for (ConfigurationListener listener : listeners) {
try {
listener.changed(key, before, now);
} catch (RuntimeException e) {
LOG.warning(() -> "A listener failed while notifying " + key
+ ": " + e.getMessage());
}
}
if (!reloadable) {
LOG.warning(() -> String.format(
"The property %s has changed from '%s' to '%s', but it is NOT "
+ "applied hot: the application must be restarted.",
key, before, now));
}
}
LOG.info(() -> "Reload: " + changes.size() + " properties changed");
return changes;
}
private boolean isReloadable(String key) {
for (PropertyDescription d : KNOWN) {
if (d.key().equals(key)) {
return d.reloadable();
}
}
return true; // unknown: assumed reloadable
}
// ---------------------- TEMPLATE ----------------------
/**
* Generates a commented .properties with all the known keys.
*
* It is written by hand rather than with Properties.store because store
* preserves neither the order nor the comments (section 11), and a
* template with no comments and no order is of no use at all.
*/
public void exportTemplate(Path target) throws IOException {
Files.createDirectories(target.toAbsolutePath().getParent());
Path temp = target.resolveSibling(target.getFileName() + ".tmp");
boolean completed = false;
try {
try (BufferedWriter output = Files.newBufferedWriter(temp, CHARSET,
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING,
StandardOpenOption.WRITE)) {
output.write("# ============================================================");
output.newLine();
output.write("# BiblioTech configuration - Nexus Software");
output.newLine();
output.write("# Template generated automatically.");
output.newLine();
output.write("#");
output.newLine();
output.write("# Precedence, from lowest to highest:");
output.newLine();
output.write("# 1. code defaults");
output.newLine();
output.write("# 2. this file");
output.newLine();
output.write("# 3. environment variables (BIBLIOTECH_XXX_YYY)");
output.newLine();
output.write("# 4. system properties (-Dbibliotech.xxx.yyy)");
output.newLine();
output.write("#");
output.newLine();
output.write("# WARNING: do NOT put passwords or keys in this file");
output.newLine();
output.write("# if it goes into version control. Use environment variables.");
output.newLine();
output.write("# ============================================================");
output.newLine();
output.newLine();
for (PropertyDescription d : KNOWN) {
String current = values.getOrDefault(d.key(), d.defaultValue());
String from = source.getOrDefault(d.key(), "code default");
output.write("# " + d.description());
output.newLine();
output.write("# default : " + d.defaultValue());
output.newLine();
output.write("# current value: " + current + " (source: " + from + ")");
output.newLine();
output.write("# hot reload: " + (d.reloadable()
? "YES" : "NO, requires a restart"));
output.newLine();
output.write(d.key() + " = " + current);
output.newLine();
output.newLine();
}
}
Files.move(temp, target,
StandardCopyOption.REPLACE_EXISTING,
StandardCopyOption.ATOMIC_MOVE);
completed = true;
LOG.info(() -> "Configuration template generated in " + target);
} catch (AtomicMoveNotSupportedException e) {
Files.move(temp, target, StandardCopyOption.REPLACE_EXISTING);
completed = true;
} finally {
if (!completed) {
Files.deleteIfExists(temp);
}
}
}
// ---------------------- ACCESS ----------------------
public String getText(String key, String defaultValue) {
return values.getOrDefault(key, defaultValue);
}
public String dump() {
StringBuilder sb = new StringBuilder("=== EFFECTIVE CONFIGURATION ===\n");
for (var e : values.entrySet()) {
sb.append(String.format(" %-42s = %-16s [%s]%n",
e.getKey(), e.getValue(), source.get(e.getKey())));
}
return sb.toString();
}
// ---------------------- DEMONSTRATION ----------------------
public static void main(String[] args) throws IOException {
Path file = Path.of("data/bibliotech.properties");
Files.createDirectories(file.getParent());
Files.writeString(file,
"bibliotech.loan.days = 15\n"
+ "bibliotech.csv.separator = ,\n", CHARSET);
ReloadableConfiguration config = new ReloadableConfiguration(file);
// A listener reacting to the changes
config.addListener((key, before, now) ->
System.out.printf(" [LISTENER] %s: '%s' -> '%s'%n", key, before, now));
System.out.println(config.dump());
// A commented template for the administrator
Path template = Path.of("data/bibliotech-template.properties");
config.exportTemplate(template);
System.out.println("=== TEMPLATE (first lines) ===");
try (var lines = Files.lines(template, CHARSET)) { // close the stream (07-06)
int[] n = { 0 };
lines.forEach(l -> {
if (n[0]++ < 20) {
System.out.println(" " + l);
}
});
}
// Change the file and reload
System.out.println();
System.out.println("=== MODIFYING THE FILE AND RELOADING ===");
Files.writeString(file,
"bibliotech.loan.days = 21\n"
+ "bibliotech.csv.separator = ;\n"
+ "bibliotech.log.level = FINE\n", CHARSET);
List<Change> changes = config.reload();
System.out.println();
System.out.println("=== CHANGES DETECTED ===");
for (Change c : changes) {
System.out.println(" " + c.line());
}
}
}Output (a fragment):
=== MODIFYING THE FILE AND RELOADING ===
[LISTENER] bibliotech.csv.separator: ',' -> ';'
[LISTENER] bibliotech.loan.days: '15' -> '21'
[LISTENER] bibliotech.log.level: 'INFO' -> 'FINE'
=== CHANGES DETECTED ===
bibliotech.csv.separator , -> ;
bibliotech.loan.days 15 -> 21 [REQUIRES RESTART]
bibliotech.log.level INFO -> FINEThe four teaching points:
- The distinction between reloadable and non-reloadable is a business one, not a technical one. Changing the CSV separator hot is harmless. Changing the fine rate halfway through a run would mean two loans on the same day being computed with different rules, and that, in a system that charges money, is unacceptable. The configuration marks the difference and warns instead of applying it silently.
- A listener that fails does not break the reload. The
try/catcharound the notification is the policy of 06-07: a failure in a peripheral part must not bring down the main operation. - The template is written by hand, not with
store().Properties.storepreserves neither the order nor the comments, and a template with no comments and no order is of no use at all. It is the limitation of section 11 in action. - The template includes the warning about credentials. Documenting the security policy in the very place where somebody is going to be tempted to break it is more effective than documenting it in a wiki nobody opens.
Conclusion
You have closed the input/output module, and with it a very large part of what is needed to write real software.
In this lesson you have seen why a text format beats a binary one for interchange —readable, diffable in version control, language-independent and safe with foreign data— and what you pay in exchange: size, speed, types and complex structures.
You know CSV properly: its six RFC 4180 rules, and the six cases where the naive split(",") fails —separators inside quoted fields, doubled quotes, line breaks inside a field, empty fields at the end that split discards if you forget the -1, and the invisible BOM that makes the first column match nothing. You know that a regular expression does not solve this and that a two-state machine does, in forty lines. You have your CsvReader and your CsvWriter with their escape/unescape, their edge-case table and the honest limitation no escaping fixes: CSV does not distinguish null from the empty string. And you have the only serious way of validating a format: the round-trip test, write, read and compare —what was missing from the sanitise() of 07-02 and would have exposed the failure on the first try.
You know how to navigate the conflict of the separator and the European decimal comma: comma and dot for interchange, semicolon for a European Excel, Locale.ROOT when writing, and Double.parseDouble that always uses the dot while String.format obeys the system. With the principle that resolves it: tolerant when reading, strict when writing. And you have the honest recommendation about libraries: implement it once to understand it, use the library in production —OpenCSV, Commons CSV and Jackson, in 11-07—, because you now know what to ask them and you will know how to diagnose when they give an odd result.
You know Properties: its key-value format, its escapes, its comments, the historical ISO-8859-1 problem and why you must use the Reader/Writer overloads with an explicit charset; the getProperty with a default value that avoids a useless null; the default chaining with its two traps —keySet() versus stringPropertyNames(), and the store that saves neither the defaults nor the order nor the comments—; and the warning to use getProperty/setProperty and never the get/put inherited from Hashtable.
And you have the complete configuration hierarchy, with its five levels and its reasoned precedence: code defaults as the safety net, the classpath file as the factory values, the external file for the deployment, environment variables for containers and -D for immediate adjustment. With the convention BIBLIOTECH_LOAN_DAYS ↔ bibliotech.loan.days, and with the diagnostic dump that says where each value comes from and solves in a minute the classic "I changed the file and it takes no notice".
The four constants are no longer constants. LOAN_DAYS, DAILY_RATE, MAX_FINE and MINOR_THRESHOLD are read from an external file, with validation, with default values and with the distinction of 06-07 applied to start-up: a missing file degrades —the application starts with the defaults and logs it— and an invalid value aborts with a distinguishable exit code, because computing fines with a negative rate produces incorrect charges, and that is worse than not starting. Changing the fine rate is now editing a line of text, not recompiling and deploying.
And you know that credentials never go in the repository file, because Git history is permanent, repositories are cloned and made public by mistake, and bots crawl constantly. With the four alternatives —environment variables, an external file with restricted permissions, a secrets manager, an ignored local file— and with the two code rules: no default value for a secret, and never log it. With the formal warning, once again, that this is defined and reviewed by the organisation's security officer, and that application security is covered in 12-07.
And the whole module. You started not knowing what a file was beyond an icon; now you know it is a sequence of bytes with a name, that reaching it crosses an expensive frontier —the system call— and that the entire design of Java I/O exists to cross it fewer times. You know how to locate files with absolute and relative paths and you know why the same program "cannot find" them depending on where it is launched from. You know how to read and write text with the charset always explicit, because encoding cannot be detected and getting it wrong corrupts silently. You know that a forgotten append parameter destroys whole files with no warning, and that atomic writing —a temporary file and a rename— is the only way to regenerate a file without risking losing it.
You understand the architecture of java.io: the stream as a unidirectional, agnostic abstraction, the two hierarchies of bytes and characters and why there had to be two, node classes as against filter classes, the Decorator pattern that turns twelve classes into five, and the bridges where the encoding is decided. You know what a buffer does and why it gives a factor of sixty, you have mastered the contract of readLine() and the canonical loop, and you know how to process a one-gigabyte file with constant memory. You know serialisation and —with the same emphasis— its real risks and when not to use it. And you have mastered NIO.2, with Path, Files, ATOMIC_MOVE, StandardOpenOption and tree walking, which is what you will be writing from now on.
BiblioTech, at the close of module 7, remembers. It loads its catalogue at start-up from a correct CSV that does not lose a single comma from any title, and saves it on exit atomically with a rotating backup. It imports inventories of thousands of lines validating each one and returning a report of what was loaded and what was discarded with the reason for each discard. It saves and restores working sessions between runs, with validation on deserialisation and a security filter. It manages its books' binary covers without corrupting them. It organises its reports in a directory tree and locates them by walking it. It records every operation in an audit log that grows instead of overwriting itself. And it configures itself without recompiling, with five configuration sources, defined precedence, validation at start-up and a dump saying where each value comes from. Of the five fragilities you declared at the close of module 5, none remains.
But there is something BiblioTech still does in only one way: one thing after another. When it imports a fifty-thousand-line catalogue, the application goes mute until it finishes: you cannot cancel it, you do not know how far along it is, and the menu does not respond. When it sends the due-date notices to two hundred employees, it sends them one after another, and if the second takes three seconds, number two hundred waits ten minutes. When it is waiting for somebody to type a menu option, the processor is completely idle, doing absolutely nothing, even though there are four reports pending generation. And if tomorrow two employees used BiblioTech at once on the same catalogue, there is nothing in the code stopping one from trampling the other's work.
All of that has a name: the program is sequential, and the machines it runs on have not been sequential for fifteen years. Your laptop has eight cores and BiblioTech uses one.
In module 8, Multithreading and Concurrency, that is solved. You will see what a thread is and how it differs from a process; how they are created with Thread and Runnable, and why you should almost never create them directly yourself; the complete life cycle of a thread and what exactly each state means; synchronisation with synchronized, volatile and the Java memory model, which explains why a counter incremented by two threads can end up lower than it should be; the concurrency utilities —ExecutorService, CountDownLatch, Semaphore— that replace manual thread management with something you can reason about; the concurrent and atomic collections that make safe what HashMap cannot guarantee; and CompletableFuture, the modern way of chaining asynchronous work without blocking anybody. By the end of it, BiblioTech will import its catalogue showing progress and able to be cancelled, will send its two hundred notices in parallel, will serve the menu while generating reports in the background, and will protect its catalogue from two employees working at once. It will stop waiting.
Java Programming Course
Module 1: Introduction to Java
- Introduction to Java
- Setting Up the Development Environment
- Basic Syntax and Structure
- Variables and Data Types
- Operators
- Console Input and Output
- Your First Complete Program: BiblioTech
Module 2: Control Flow
- Conditional Statements
- Loops
- Switch Statements
- Break and Continue
- Debugging and Execution Traces
- Project: The BiblioTech Interactive Menu
Module 3: Object-Oriented Programming
- Introduction to OOP
- Classes and Objects
- Methods
- Constructors
- Inheritance
- Polymorphism
- Encapsulation
- Abstraction
- The Object Class: equals, hashCode and toString
Module 4: Advanced Object-Oriented Programming
- Interfaces
- Abstract Classes
- Inner Classes
- Anonymous Classes
- Lambda Expressions
- Functional Interfaces and Method References
- Enums and Records
Module 5: Data Structures and Collections
- Arrays
- The Collections Framework
- ArrayList
- LinkedList
- HashMap
- HashSet
- Queue and Deque
- Stack
- Sorting and Searching Collections
Module 6: Exception Handling
- Introduction to Exceptions
- The Try-Catch Block
- Throw and Throws
- Custom Exceptions
- The Finally Block
- Try-with-resources and AutoCloseable
- Error Handling Strategies and Logging
Module 7: File Input/Output
- Reading Files
- Writing Files
- File Streams
- BufferedReader and BufferedWriter
- Serialization
- The NIO.2 API: Path and Files
- Interchange Formats: CSV and Properties
Module 8: Multithreading and Concurrency
- Introduction to Multithreading
- Creating Threads
- Thread Lifecycle
- Synchronization
- Concurrency Utilities
- Concurrent Collections and Atomic Variables
- Asynchronous Tasks with CompletableFuture
Module 9: Networking
- Introduction to Networking
- Sockets
- ServerSocket
- DatagramSocket and DatagramPacket
- URL and HttpURLConnection
- The Modern HTTP Client
Module 10: Advanced Topics
- Generics
- Annotations
- Reflection
- Java 8 Features: Streams and Optional
- Dates and Times with java.time
- Java 9 and Beyond
- Memory, Garbage Collection and Performance
Module 11: Java Frameworks and Libraries
- Introduction to Java Frameworks
- Spring Framework
- Hibernate
- JUnit
- Maven
- Advanced Testing with Mockito
- Essential Ecosystem Libraries
