Series MapLesson 05 / 32
Start HereOrdered learning track

Learn Java Io Modern Io Resource Boundaries Part 005 Decorator Stream Patterns

16 min read3190 words
PrevNext
Lesson 0532 lesson track0106 Start Here

title: Learn Java IO, Modern IO, Streams, Buffers, Resources, Serialization & Data Boundaries - Part 005 description: Decorator stream patterns in classic Java IO: filter streams, buffered streams, data streams, print streams, pushback streams, sequence streams, custom wrappers, ordering rules, close propagation, and production failure modes. series: learn-java-io-modern-io-resource-boundaries seriesTitle: Learn Java IO, Modern IO, Streams, Buffers, Resources, Serialization & Data Boundaries order: 5 partTitle: Decorator Stream Patterns tags:

  • java
  • io
  • streams
  • decorator-pattern
  • filters
  • resources
  • series date: 2026-06-30

Part 005 — Decorator Stream Patterns

Classic Java IO is not just a set of classes. It is a compositional model.

The core idea is simple:

A raw byte or character source is usually too primitive for application logic. We wrap it with layers that add buffering, decoding, framing, counting, validation, compression, encryption, parsing, or presentation behavior.

This is the reason Java IO has many classes that look repetitive at first:

InputStream raw = Files.newInputStream(path);
InputStream buffered = new BufferedInputStream(raw);
DataInputStream data = new DataInputStream(buffered);

This chain is not accidental ceremony. It encodes a boundary pipeline.

A top-tier engineer does not memorize all wrappers. They understand:

  1. which layer owns the resource,
  2. which layer transforms bytes,
  3. which layer changes buffering behavior,
  4. which layer exposes semantic operations,
  5. which layer must be closed or flushed,
  6. which layer hides errors,
  7. which layer is safe to expose across an API boundary.

1. Kaufman Framing: What Skill Are We Acquiring?

In the Kaufman model, we deconstruct the skill into small decisions that can be practiced independently.

For decorator streams, the real skill is not “know BufferedInputStream”. The real skill is:

Given an IO boundary, construct a stream pipeline whose ordering, buffering, close behavior, error handling, and semantic contract are correct.

The Sub-Skills

Sub-skillWhat you must be able to decide
Source/sink recognitionIs the data coming from file, socket, memory, process, archive, classpath, or custom provider?
Byte-vs-character boundaryIs this binary data, encoded text, or structured records?
Wrapper orderingWhich transformations must happen first?
Buffer placementWhere should buffering be inserted, and where is it redundant?
Semantic layerShould the API expose bytes, chars, lines, primitive values, records, or domain objects?
Close ownershipWho closes the chain? Is the stream borrowed or owned?
Flush/finish correctnessDoes the outer wrapper need to write trailers, checksums, or buffered bytes?
Failure visibilityDoes the wrapper throw, swallow, defer, or expose errors through side channels?

The Practice Loop

For every stream chain you write, ask:

What is the raw resource?
What is the byte/char boundary?
What is the semantic boundary?
Where is the buffer?
What happens on EOF?
What happens on partial read/write?
What happens on close?
What happens if the consumer stops early?

That checklist prevents most production IO bugs.


2. The Decorator Model in Java IO

The classic Java IO design is heavily based on wrapping.

A source stream provides primitive operations:

int read()
int read(byte[] b, int off, int len)
void close()

A decorator stream holds another stream and delegates to it while adding behavior.

A simplified version looks like this:

final class CountingInputStream extends FilterInputStream {
    private long count;

    CountingInputStream(InputStream in) {
        super(Objects.requireNonNull(in));
    }

    @Override
    public int read() throws IOException {
        int value = super.read();
        if (value != -1) {
            count++;
        }
        return value;
    }

    @Override
    public int read(byte[] b, int off, int len) throws IOException {
        int n = super.read(b, off, len);
        if (n > 0) {
            count += n;
        }
        return n;
    }

    long count() {
        return count;
    }
}

The wrapper does not own storage, a file path, or a socket. It owns behavior around another stream.

Class Relationship

The same conceptual shape exists for output streams:


3. Stream Chains Are Boundary Pipelines

A stream chain should be read from raw resource to application semantics.

Example: reading compressed binary records from a file.

try (InputStream raw = Files.newInputStream(path);
     InputStream buffered = new BufferedInputStream(raw);
     InputStream gzip = new GZIPInputStream(buffered);
     DataInputStream data = new DataInputStream(gzip)) {

    int version = data.readUnsignedShort();
    long recordCount = data.readLong();

    for (long i = 0; i < recordCount; i++) {
        int length = data.readInt();
        byte[] payload = data.readNBytes(length);
        process(payload);
    }
}

The chain means:

file bytes
  -> buffered file bytes
  -> decompressed bytes
  -> primitive binary reads
  -> application records

Ordering matters. This is correct:

new DataInputStream(new GZIPInputStream(new BufferedInputStream(raw)))

This is nonsensical for the same file format:

new GZIPInputStream(new DataInputStream(new BufferedInputStream(raw)))

DataInputStream does not make compressed bytes magically structured before decompression. The protocol order must match the physical encoding order.


4. Layer Taxonomy

It helps to classify IO classes by responsibility.

Layer TypeExamplesResponsibility
Raw source/sinkFileInputStream, FileOutputStream, ByteArrayInputStream, ByteArrayOutputStream, socket streamsConnect to actual bytes
Buffering decoratorBufferedInputStream, BufferedOutputStream, BufferedReader, BufferedWriterReduce call overhead, batch reads/writes
Semantic decoratorDataInputStream, DataOutputStream, ObjectInputStream, ObjectOutputStreamInterpret or emit structured values
Text bridgeInputStreamReader, OutputStreamWriterConvert bytes to characters using a charset
Text convenienceBufferedReader, PrintWriterLines, formatted text, character buffering
Lookahead decoratorPushbackInputStream, PushbackReaderAllow parser to unread a small amount
ConcatenationSequenceInputStreamTreat multiple input streams as one stream
Error-swallowing presentationPrintStream, PrintWriterConvenient printing with deferred error reporting
Transformation decoratorGZIPInputStream, ZipInputStream, CipherInputStream, DigestInputStreamTransform, inspect, compress, verify, or encode data

This taxonomy is more useful than memorizing inheritance trees.


5. Pattern: Buffering Wrapper

Problem

Calling the underlying resource for every byte or small array can be expensive.

Pattern

Wrap the raw stream near the resource boundary.

try (InputStream in = new BufferedInputStream(Files.newInputStream(path))) {
    byte[] header = in.readNBytes(16);
    // parse header
}

For output:

try (OutputStream out = new BufferedOutputStream(Files.newOutputStream(path))) {
    for (Record record : records) {
        out.write(encode(record));
    }
}

Why Near the Resource Boundary?

Buffering is most useful when it reduces expensive calls to the underlying source or sink. Placing it near the raw stream usually gives the best effect.

Common shape:

new DataInputStream(new BufferedInputStream(Files.newInputStream(path)))

Not usually useful:

new BufferedInputStream(new ByteArrayInputStream(bytes))

A ByteArrayInputStream is already memory-backed. Adding BufferedInputStream usually adds complexity without reducing expensive IO.

Rule

Buffer at expensive boundaries, not blindly at every layer.


6. Pattern: Semantic Binary Wrapper

DataInputStream and DataOutputStream read and write Java primitive values in a portable binary form.

Example:

record UserRecord(int id, long createdAtEpochMillis, boolean active) {}

static void writeUser(OutputStream output, UserRecord user) throws IOException {
    DataOutputStream out = new DataOutputStream(output);
    out.writeInt(user.id());
    out.writeLong(user.createdAtEpochMillis());
    out.writeBoolean(user.active());
}

static UserRecord readUser(InputStream input) throws IOException {
    DataInputStream in = new DataInputStream(input);
    return new UserRecord(
            in.readInt(),
            in.readLong(),
            in.readBoolean()
    );
}

This is not a complete file format. It lacks:

  1. magic bytes,
  2. version,
  3. record length,
  4. checksum,
  5. schema evolution strategy,
  6. EOF policy,
  7. corruption recovery policy.

A production binary format should generally look more like this:

magic bytes
format version
header length
header payload
record length
record payload
record length
record payload
...
trailer/checksum

DataInputStream is useful for primitives. It is not a file format by itself.


7. Pattern: Text Bridge Wrapper

Text is not bytes. Text is bytes plus an encoding.

The byte-to-character boundary belongs at exactly one place.

try (Reader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) {
    char[] chars = new char[4096];
    while (true) {
        int n = reader.read(chars);
        if (n == -1) {
            break;
        }
        consume(chars, 0, n);
    }
}

Equivalent explicit chain:

try (InputStream raw = Files.newInputStream(path);
     Reader decoded = new InputStreamReader(raw, StandardCharsets.UTF_8);
     BufferedReader reader = new BufferedReader(decoded)) {

    String line;
    while ((line = reader.readLine()) != null) {
        process(line);
    }
}

The chain means:

file bytes -> UTF-8 decoded chars -> buffered character reader -> lines

Anti-Pattern: Decode Too Early or Too Often

Bad:

String text = new String(bytes); // platform default charset; avoid

Better:

String text = new String(bytes, StandardCharsets.UTF_8);

But for large data, avoid materializing all text:

try (BufferedReader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) {
    reader.lines().forEach(this::processLine);
}

Even here, be careful: reader.lines() is lazy and depends on the reader remaining open. Do not return the stream after closing the reader.


8. Pattern: Lookahead Wrapper

Parsers often need to read one byte or character to decide what to do next, then push it back.

PushbackInputStream supports this pattern.

try (PushbackInputStream in = new PushbackInputStream(
        new BufferedInputStream(Files.newInputStream(path)),
        8
)) {
    int first = in.read();
    if (first == '#') {
        skipComment(in);
    } else if (first != -1) {
        in.unread(first);
        parseRecord(in);
    }
}

Use cases:

  1. protocol sniffing,
  2. optional header detection,
  3. simple tokenizers,
  4. BOM detection,
  5. lightweight parsers.

Boundary Warning

Pushback is local lookahead. It is not a general rewind mechanism.

If you need arbitrary rewind:

  1. use a seekable file/channel,
  2. use a bounded memory buffer,
  3. spool to disk,
  4. redesign the protocol.

9. Pattern: Sequence Wrapper

SequenceInputStream concatenates multiple input streams.

try (InputStream first = Files.newInputStream(headerPath);
     InputStream second = Files.newInputStream(bodyPath);
     InputStream combined = new SequenceInputStream(first, second)) {

    combined.transferTo(output);
}

This is useful when a consumer expects a single stream, but your data is split into several sequential segments.

Critical Limitations

SequenceInputStream is not:

  1. a merge operation,
  2. a framing protocol,
  3. a multiplexing mechanism,
  4. a safe replacement for structured archive handling,
  5. a way to preserve source boundaries unless the protocol carries lengths.

The consumer sees one continuous byte sequence. If the boundaries matter, encode them explicitly.


10. Pattern: Counting, Limiting, and Guard Wrappers

The JDK does not provide every production wrapper you need. You will often create small wrappers to enforce boundaries.

Limiting Input

A common production need is to prevent a parser from reading more than the allowed payload size.

final class LimitedInputStream extends FilterInputStream {
    private long remaining;

    LimitedInputStream(InputStream in, long limit) {
        super(Objects.requireNonNull(in));
        if (limit < 0) {
            throw new IllegalArgumentException("limit must be >= 0");
        }
        this.remaining = limit;
    }

    @Override
    public int read() throws IOException {
        if (remaining == 0) {
            return -1;
        }
        int value = super.read();
        if (value != -1) {
            remaining--;
        }
        return value;
    }

    @Override
    public int read(byte[] b, int off, int len) throws IOException {
        Objects.checkFromIndexSize(off, len, b.length);
        if (remaining == 0) {
            return -1;
        }
        int allowed = (int) Math.min(len, remaining);
        int n = super.read(b, off, allowed);
        if (n > 0) {
            remaining -= n;
        }
        return n;
    }
}

Usage:

try (InputStream payload = new LimitedInputStream(raw, maxPayloadBytes)) {
    parse(payload);
}

This wrapper creates a local EOF after limit bytes. That is useful for nested formats where each section has a declared length.

Guardrail

A limited stream should not close the underlying stream if the underlying stream must continue after the section. In such cases, either:

  1. do not close the wrapper, or
  2. implement a wrapper whose close() drains or detaches instead of closing, or
  3. expose a method that reads exactly the section bytes and returns control to the outer parser.

This is where ownership semantics matter.


11. Pattern: Tee Output

Sometimes you need to write to two destinations: for example, store a payload and compute a side log or mirror.

The JDK has no general-purpose TeeOutputStream, but it is easy to implement.

final class TeeOutputStream extends OutputStream {
    private final OutputStream left;
    private final OutputStream right;

    TeeOutputStream(OutputStream left, OutputStream right) {
        this.left = Objects.requireNonNull(left);
        this.right = Objects.requireNonNull(right);
    }

    @Override
    public void write(int b) throws IOException {
        left.write(b);
        right.write(b);
    }

    @Override
    public void write(byte[] b, int off, int len) throws IOException {
        left.write(b, off, len);
        right.write(b, off, len);
    }

    @Override
    public void flush() throws IOException {
        IOException failure = null;
        try {
            left.flush();
        } catch (IOException e) {
            failure = e;
        }
        try {
            right.flush();
        } catch (IOException e) {
            if (failure != null) {
                failure.addSuppressed(e);
            } else {
                failure = e;
            }
        }
        if (failure != null) {
            throw failure;
        }
    }

    @Override
    public void close() throws IOException {
        IOException failure = null;
        try {
            left.close();
        } catch (IOException e) {
            failure = e;
        }
        try {
            right.close();
        } catch (IOException e) {
            if (failure != null) {
                failure.addSuppressed(e);
            } else {
                failure = e;
            }
        }
        if (failure != null) {
            throw failure;
        }
    }
}

The important part is not teeing. The important part is error handling on close.

If one sink fails during close, you still need to attempt closing the other sink.


12. Pattern: Print Streams for Human-Oriented Output

PrintStream and PrintWriter are convenient, but dangerous in protocol code.

Example:

try (PrintWriter writer = new PrintWriter(
        Files.newBufferedWriter(path, StandardCharsets.UTF_8)
)) {
    writer.println("id,name,status");
    writer.printf(Locale.ROOT, "%d,%s,%s%n", id, name, status);

    if (writer.checkError()) {
        throw new IOException("failed to write report");
    }
}

Why Dangerous?

PrintWriter and PrintStream generally do not throw IOException from normal write methods. They record error state. You need checkError() if the write result matters.

This design is acceptable for simple console-style output. It is usually a poor fit for:

  1. financial files,
  2. regulatory exports,
  3. protocol writers,
  4. audit logs,
  5. transactional batch output,
  6. anything where write failure must abort the operation.

For production data files, prefer explicit Writer or OutputStream operations that propagate IOException.


13. Close Propagation: Close the Outermost Layer

Given this chain:

try (DataOutputStream out = new DataOutputStream(
        new BufferedOutputStream(
                Files.newOutputStream(path)))) {
    out.writeInt(42);
}

The outer DataOutputStream closes its wrapped stream, which closes the BufferedOutputStream, which flushes and closes the underlying file stream.

Best Practice

Declare only the outermost stream in try-with-resources when ownership is unambiguous.

try (DataOutputStream out = new DataOutputStream(
        new BufferedOutputStream(Files.newOutputStream(path)))) {
    out.writeLong(123L);
}

This is usually cleaner than declaring every intermediate layer.

However, declaring intermediate layers can be useful when:

  1. you need direct access to a layer,
  2. resources are constructed before the final wrapper,
  3. wrapper constructors can throw and you need precise cleanup,
  4. different layers have independent ownership.

14. Wrapper Constructor Failure

A subtle resource leak can happen when a raw resource is opened, then a wrapper constructor fails before try-with-resources owns it.

Risky shape:

InputStream raw = Files.newInputStream(path);
InputStream gzip = new GZIPInputStream(raw); // may throw
try (InputStream in = gzip) {
    // use in
}

If new GZIPInputStream(raw) throws because the file is not a valid GZIP stream, raw is not closed unless you handle it.

Safer:

try (InputStream raw = Files.newInputStream(path);
     InputStream buffered = new BufferedInputStream(raw);
     InputStream gzip = new GZIPInputStream(buffered)) {
    gzip.transferTo(OutputStream.nullOutputStream());
}

Now each successfully constructed resource is registered for cleanup.

Trade-Off

This makes the resource list longer. For risky wrappers such as compression, encryption, archive, or object stream constructors, explicit resource registration can be worth it.


15. Ordering Rules for Common Chains

Reading UTF-8 Text From a File

try (BufferedReader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) {
    // lines/chars
}

Explicit chain:

new BufferedReader(new InputStreamReader(Files.newInputStream(path), StandardCharsets.UTF_8))

Writing UTF-8 Text

try (BufferedWriter writer = Files.newBufferedWriter(path, StandardCharsets.UTF_8)) {
    writer.write("hello");
    writer.newLine();
}

Reading Compressed Text

try (InputStream raw = Files.newInputStream(path);
     InputStream buffered = new BufferedInputStream(raw);
     InputStream gzip = new GZIPInputStream(buffered);
     Reader decoder = new InputStreamReader(gzip, StandardCharsets.UTF_8);
     BufferedReader reader = new BufferedReader(decoder)) {

    reader.lines().forEach(this::processLine);
}

Pipeline:

file bytes -> buffered bytes -> decompressed bytes -> UTF-8 chars -> lines

Writing Compressed Text

try (OutputStream raw = Files.newOutputStream(path);
     OutputStream buffered = new BufferedOutputStream(raw);
     OutputStream gzip = new GZIPOutputStream(buffered);
     Writer encoder = new OutputStreamWriter(gzip, StandardCharsets.UTF_8);
     BufferedWriter writer = new BufferedWriter(encoder)) {

    writer.write("record-1");
    writer.newLine();
}

Pipeline:

chars -> UTF-8 bytes -> compressed bytes -> buffered file bytes -> file

The output order is the reverse conceptual path of reading.

Binary File With Header and Records

try (DataOutputStream out = new DataOutputStream(
        new BufferedOutputStream(Files.newOutputStream(path)))) {

    out.writeInt(0x4A494F31); // JIO1
    out.writeShort(1);        // version

    for (Payload payload : payloads) {
        byte[] encoded = payload.encode();
        out.writeInt(encoded.length);
        out.write(encoded);
    }
}

16. Mark/Reset and Wrapper Semantics

BufferedInputStream can add mark/reset support even when the underlying stream does not support it, as long as the read-ahead limit is respected.

try (BufferedInputStream in = new BufferedInputStream(Files.newInputStream(path))) {
    if (in.markSupported()) {
        in.mark(1024);
        byte[] probe = in.readNBytes(16);
        inspect(probe);
        in.reset();
    }
}

Invariant

mark/reset is a bounded lookback contract, not a promise of infinite rewind.

If you read beyond the mark limit, reset() may fail.

Production Advice

Use mark/reset for small local probes. Do not use it to implement large rollback semantics.

For large rollback, prefer:

  1. SeekableByteChannel,
  2. FileChannel.position(...),
  3. bounded spool files,
  4. explicit protocol framing.

17. available() Is Not File Size

Many engineers misuse available().

Bad:

byte[] bytes = new byte[in.available()];
in.read(bytes);

Problems:

  1. available() is not total stream length.
  2. It may return zero even when future data can arrive.
  3. read(bytes) is not guaranteed to fill the array.
  4. It breaks for sockets, pipes, compressed streams, and many custom streams.

Better for bounded files:

byte[] bytes = Files.readAllBytes(path);

Better for streams:

byte[] buffer = new byte[8192];
while (true) {
    int n = in.read(buffer);
    if (n == -1) {
        break;
    }
    process(buffer, 0, n);
}

Better for declared-length protocol sections:

byte[] payload = in.readNBytes(length);
if (payload.length != length) {
    throw new EOFException("truncated payload");
}

18. Partial Reads and Wrapper Correctness

A wrapper must respect the InputStream contract. The hardest part is partial reads.

Wrong custom wrapper:

@Override
public int read(byte[] b, int off, int len) throws IOException {
    int n = in.read(b, off, len);
    count += len; // wrong
    return n;
}

Correct:

@Override
public int read(byte[] b, int off, int len) throws IOException {
    int n = in.read(b, off, len);
    if (n > 0) {
        count += n;
    }
    return n;
}

Why?

  1. read may return fewer bytes than requested.
  2. read may return -1 for EOF.
  3. Some streams may return 0 for zero-length reads.
  4. You must count actual bytes transferred, not requested bytes.

19. Partial Writes and Wrapper Correctness

OutputStream.write(byte[], off, len) is specified to write all requested bytes or throw, but lower-level abstractions such as channels may perform partial writes.

When adapting a WritableByteChannel to stream-like behavior, you must loop.

static void writeFully(WritableByteChannel channel, ByteBuffer buffer) throws IOException {
    while (buffer.hasRemaining()) {
        int n = channel.write(buffer);
        if (n == 0) {
            Thread.onSpinWait();
        }
    }
}

For non-blocking channels, this simple spin loop is wrong. You need selector-driven readiness and backpressure. That appears later in the NIO selector part.

The point here is the mental model:

Stream output usually hides partial writes. Channel output exposes them.


20. Flush Semantics

flush() pushes buffered data to the next layer. It does not necessarily make data durable.

Example:

try (BufferedOutputStream out = new BufferedOutputStream(Files.newOutputStream(path))) {
    out.write(payload);
    out.flush();
}

This ensures the BufferedOutputStream passes its bytes to the underlying stream. It does not guarantee the filesystem has persisted the bytes to stable storage.

Durability is a separate concern covered in the crash consistency part.

Flush Chain

Practical Rules

  1. Use flush() when a downstream consumer needs data before close.
  2. Avoid flushing after every small write unless latency demands it.
  3. Closing an output stream usually flushes first, but explicit flush() can make failure timing clearer.
  4. Do not confuse flush with durable commit.

21. finish() vs close() in Transforming Streams

Some output wrappers write trailers or final blocks.

Examples:

  1. GZIPOutputStream writes compression trailer/checksum.
  2. ZipOutputStream writes entry metadata and central directory.
  3. CipherOutputStream may need final block handling.
  4. ObjectOutputStream has stream protocol state.

For these streams, merely flushing is not enough.

ByteArrayOutputStream bytes = new ByteArrayOutputStream();
try (GZIPOutputStream gzip = new GZIPOutputStream(bytes)) {
    gzip.write(payload);
}
byte[] compressed = bytes.toByteArray();

Do not read bytes.toByteArray() before the GZIPOutputStream is closed or finished. The compressed data may be incomplete.

If you need to keep the underlying stream open, use finish() where the API provides it.

GZIPOutputStream gzip = new GZIPOutputStream(out);
gzip.write(payload);
gzip.finish(); // completes gzip stream without necessarily closing 'out'

Boundary Rule

flush() is for buffered bytes. finish() is for format completion. close() is for resource lifecycle.

They are related but not equivalent.


22. The Outermost Type Is the API Contract

This method exposes a byte stream:

void importPayload(InputStream input) throws IOException

This method exposes text:

void importCsv(Reader reader) throws IOException

This method exposes a file boundary:

void importFile(Path path) throws IOException

This method exposes a repeatable byte source:

void importPayload(Supplier<? extends InputStream> source) throws IOException

Each API has different semantics.

API shapeConsumer can assumeConsumer cannot assume
InputStreamone-pass bytesseek, replay, length, charset
Readerone-pass charsoriginal bytes, encoding, byte offsets
Pathfilesystem objectstable contents unless controlled
byte[]in-memory complete payloadstreaming scalability
Supplier<InputStream>replayable if documentedsame content unless guaranteed
SeekableByteChannelposition-based accesstext decoding semantics

The outermost abstraction is not cosmetic. It is the contract.


23. Good and Bad Pipeline Examples

Good: Bounded Upload Processing

void processUpload(InputStream upload, long declaredLength, long maxBytes) throws IOException {
    if (declaredLength < 0 || declaredLength > maxBytes) {
        throw new IOException("invalid upload length: " + declaredLength);
    }

    try (InputStream limited = new LimitedInputStream(upload, declaredLength);
         InputStream buffered = new BufferedInputStream(limited)) {
        parseUpload(buffered);
    }
}

This makes the boundary explicit.

Bad: Unbounded Materialization

void processUpload(InputStream upload) throws IOException {
    byte[] all = upload.readAllBytes();
    parseUpload(new ByteArrayInputStream(all));
}

This is only acceptable when the input is already bounded and small by contract.

Good: Explicit Charset

try (BufferedReader reader = new BufferedReader(
        new InputStreamReader(upload, StandardCharsets.UTF_8))) {
    parseCsv(reader);
}

Bad: Platform Default Charset

try (BufferedReader reader = new BufferedReader(new InputStreamReader(upload))) {
    parseCsv(reader);
}

This can produce different behavior across machines and deployments.


24. Production Failure Modes

Failure ModeCauseSymptomPrevention
Incomplete compressed outputRead underlying bytes before close/finishConsumer says file corruptClose/finish transform wrapper before reading underlying bytes
Silent print failurePrintWriter/PrintStream hides write exceptionMissing/truncated reportUse checkError() or avoid print wrappers for critical data
Wrong decodingDefault charsetData differs by environmentAlways specify charset
Wrapper constructor leakRaw stream opened before failing wrapperFile descriptor leakRegister raw stream in try-with-resources
Wrong wrapper orderTransform after semantic parserCorrupt parseMatch physical encoding order
Over-bufferingBuffer memory-backed stream or multiple layersExtra memory/copyingBuffer only expensive boundaries
Accidental closeHelper closes borrowed streamCaller cannot continueDocument ownership; use non-closing wrapper if needed
Misused available()Treated as total sizeTruncated readsLoop until EOF or use known length
Lost source boundariesConcatenation without framingCannot split records laterEncode lengths or metadata

25. Design Checklist

Before you commit IO wrapper code, answer these:

  1. Is the data binary, text, records, or objects?
  2. Where exactly does byte-to-char decoding happen?
  3. Are all charsets explicit?
  4. Is buffering placed near expensive boundaries?
  5. Does wrapper order match the physical data format?
  6. Does the outermost type express the correct API contract?
  7. Who owns and closes the stream?
  8. Can wrapper construction fail after raw resource acquisition?
  9. Does any wrapper need finish() before the underlying bytes are used?
  10. Does any wrapper hide errors behind checkError()?
  11. Is the input bounded before materialization?
  12. Are partial reads handled correctly?
  13. Are protocol boundaries encoded explicitly?
  14. Is available() avoided as a size indicator?
  15. Is close failure visible to the caller?

26. Practice Exercises

Exercise 1 — Fix the Chain

Given:

InputStream in = new DataInputStream(
        new BufferedInputStream(
                new GZIPInputStream(Files.newInputStream(path))));

Question:

Is this good? If not, what would you change?

Answer:

The declared type loses the fact that this is a DataInputStream. The buffering is also inside gzip, which may not be ideal because the raw compressed file reads are not buffered before decompression.

Better:

try (InputStream raw = Files.newInputStream(path);
     InputStream buffered = new BufferedInputStream(raw);
     InputStream gzip = new GZIPInputStream(buffered);
     DataInputStream in = new DataInputStream(gzip)) {
    // read structured binary values
}

Exercise 2 — Detect Silent Print Failure

Write a method that writes CSV using PrintWriter, but throws an exception if writing failed.

static void writeCsv(Path path, List<String[]> rows) throws IOException {
    try (PrintWriter writer = new PrintWriter(
            Files.newBufferedWriter(path, StandardCharsets.UTF_8))) {

        for (String[] row : rows) {
            writer.println(String.join(",", row));
        }

        if (writer.checkError()) {
            throw new IOException("CSV write failed: " + path);
        }
    }
}

Production note: this still does not escape CSV values correctly. The exercise is about stream error behavior, not CSV correctness.

Exercise 3 — Implement a Counting Output Stream

final class CountingOutputStream extends FilterOutputStream {
    private long count;

    CountingOutputStream(OutputStream out) {
        super(Objects.requireNonNull(out));
    }

    @Override
    public void write(int b) throws IOException {
        out.write(b);
        count++;
    }

    @Override
    public void write(byte[] b, int off, int len) throws IOException {
        Objects.checkFromIndexSize(off, len, b.length);
        out.write(b, off, len);
        count += len;
    }

    long count() {
        return count;
    }
}

Discussion:

For OutputStream, if write(byte[], off, len) returns normally, the requested bytes have been accepted by the stream. So count += len is correct here.

Exercise 4 — Design a Stream API

Choose the API type for each case:

Use CasePreferred API
Import a one-pass HTTP bodyInputStream
Parse a UTF-8 text configReader or Path with explicit charset
Read a large file with random accessSeekableByteChannel or FileChannel
Retry parsing from the beginningPath, byte[], or Supplier<InputStream>
Process untrusted upload with size limitInputStream plus explicit declared/max length
Generate a report filePath or OutputStream, depending on ownership

27. Mini Capstone: Safe Length-Prefixed Reader

This example combines buffering, data semantics, boundary enforcement, and partial-read correctness.

final class LengthPrefixedPayloadReader implements Closeable {
    private final DataInputStream in;
    private final int maxPayloadBytes;

    LengthPrefixedPayloadReader(InputStream source, int maxPayloadBytes) {
        if (maxPayloadBytes <= 0) {
            throw new IllegalArgumentException("maxPayloadBytes must be positive");
        }
        this.in = new DataInputStream(new BufferedInputStream(source));
        this.maxPayloadBytes = maxPayloadBytes;
    }

    Optional<byte[]> readNext() throws IOException {
        final int length;
        try {
            length = in.readInt();
        } catch (EOFException eof) {
            return Optional.empty();
        }

        if (length < 0 || length > maxPayloadBytes) {
            throw new IOException("invalid payload length: " + length);
        }

        byte[] payload = in.readNBytes(length);
        if (payload.length != length) {
            throw new EOFException("truncated payload: expected " + length + ", got " + payload.length);
        }
        return Optional.of(payload);
    }

    @Override
    public void close() throws IOException {
        in.close();
    }
}

Key points:

  1. The stream is buffered once.
  2. DataInputStream provides primitive length reads.
  3. The declared length is validated before allocation.
  4. EOF before a new length means normal end of stream.
  5. EOF inside a payload means truncation/corruption.
  6. The wrapper owns and closes the chain.

28. Summary

Decorator streams are Java IO's core composition mechanism.

The expert-level view is not “wrap everything in BufferedInputStream”. The expert-level view is:

Build a pipeline that matches the physical data representation, adds exactly the necessary semantics, exposes the correct API boundary, and preserves lifecycle/error correctness.

The key invariants are:

  1. wrapper order must match data encoding order,
  2. byte/char conversion must happen exactly once and with explicit charset,
  3. buffering belongs near expensive boundaries,
  4. close the owner of the outermost layer,
  5. flush, finish, and close are distinct operations,
  6. print wrappers may hide IO failures,
  7. available() is not length,
  8. partial reads must be handled,
  9. boundaries must be explicit if they matter,
  10. API parameter type is a semantic contract.

References

Lesson Recap

You just completed lesson 05 in start here. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.