Series MapLesson 22 / 32
Deepen PracticeOrdered learning track

Learn Java Io Modern Io Resource Boundaries Part 022 Resource Boundary Api Design

17 min read3257 words
PrevNext
Lesson 2232 lesson track1927 Deepen Practice

title: Learn Java IO, Modern IO, Streams, Buffers, Resources, Serialization & Data Boundaries - Part 022 description: API design for Java IO boundaries: choosing Path, File, InputStream, Reader, ByteBuffer, Channel, Supplier<InputStream>, callbacks, resource ownership, replayability, and boundary contracts. series: learn-java-io-modern-io-resource-boundaries seriesTitle: Learn Java IO, Modern IO, Streams, Buffers, Resources, Serialization & Data Boundaries order: 22 partTitle: API Design for IO Boundaries tags:

  • java
  • io
  • api-design
  • boundary-design
  • streams
  • nio
  • resource-management
  • series date: 2026-06-30

Part 022 — API Design for IO Boundaries

1. Why This Part Matters

Java gives us many IO shapes:

  • Path
  • File
  • URI
  • InputStream
  • OutputStream
  • Reader
  • Writer
  • byte[]
  • ByteBuffer
  • FileChannel
  • SeekableByteChannel
  • ReadableByteChannel
  • WritableByteChannel
  • Supplier<InputStream>
  • callbacks
  • Java Stream<T>

The difficult part is not knowing that these exist.

The difficult part is choosing the right one for an API boundary.

A poor IO API forces callers into unsafe behavior:

  • accidental full materialization
  • unclear close responsibility
  • impossible retry
  • hidden blocking
  • charset ambiguity
  • resource leaks
  • incorrect ownership transfer
  • poor testability
  • impossible progress/cancellation
  • incomplete security and validation boundaries

This part gives a decision framework.


2. The Core Principle

An IO type is not just a data type.

It encodes boundary semantics.

TypeHidden Semantic Question
PathCan the callee open the resource itself?
InputStreamIs the data one-shot and caller-owned?
byte[]Is the whole payload already materialized?
ByteBufferWho owns position/limit and mutation?
ReaderHas charset decoding already happened?
ChannelDo we need positional or non-blocking semantics?
Supplier<InputStream>Is the source replayable?
CallbackShould the callee own the resource lifecycle?

The API should expose the semantic contract you actually need.


3. Decision Tree

This tree is not absolute. It is a starting point.

The real design comes from invariants.


4. Boundary Questions Before Choosing a Type

Before designing any IO method, answer these questions.

4.1 Size

  • Is the payload bounded?
  • What is the maximum size?
  • Is the maximum trusted or attacker-controlled?
  • Is the maximum enforced by this method or upstream?

4.2 Lifetime

  • Who opens the resource?
  • Who closes it?
  • Can the method retain it after returning?
  • Can the method process it asynchronously?

4.3 Replayability

  • Can the source be read more than once?
  • Can failure be retried?
  • Does retry require a new stream?

4.4 Position

  • Is the source sequential only?
  • Does the API require random access?
  • Should reading mutate the caller's position?

4.5 Encoding

  • Is this binary or text?
  • If text, which charset?
  • Who handles malformed input?

4.6 Blocking

  • Can the call block?
  • Is blocking acceptable on caller's thread?
  • Is timeout/cancellation required?

4.7 Partial State

  • What happens if write fails halfway?
  • Is partial output visible?
  • Is the operation atomic?

4.8 Ownership

  • Does the callee own the resource?
  • Is ownership transferred?
  • Is the caller still allowed to use it after the method returns?

5. Path as an API Boundary

Use Path when the callee should open and manage the file.

Report parseReport(Path path) throws IOException;

This is good when the method needs:

  • metadata
  • file size
  • attributes
  • multiple passes
  • safe open options
  • retries by reopening
  • temporary file strategy
  • atomic moves
  • FileChannel
  • memory mapping
  • lifecycle ownership

Example:

static long countBytes(Path path) throws IOException {
    try (InputStream in = Files.newInputStream(path)) {
        return in.transferTo(OutputStream.nullOutputStream());
    }
}

The method opens the stream and closes it.

5.1 Advantages

AdvantageExplanation
Clear lifecycleMethod creates resource and closes it
ReplayableMethod can reopen if needed
Metadata availableCan inspect attributes, size, owner, etc.
Correct open optionsMethod controls CREATE_NEW, TRUNCATE_EXISTING, etc.
TestableCaller can use temp files

5.2 Disadvantages

DisadvantageExplanation
Filesystem-specificCannot directly pass socket/request body
Less abstractAPI tied to local or provider-backed filesystem
Race concernsPath may change between checks and opens

5.3 Good Use Cases

Use Path for:

  • file import
  • file export
  • indexing local files
  • safe replace
  • archive creation
  • checksum of local file
  • mmap
  • file metadata logic
  • APIs that need to own resource lifecycle

5.4 Bad Use Cases

Avoid Path when:

  • data may come from network or memory
  • caller already owns an open stream
  • source is one-shot
  • content is not filesystem-backed

6. File as an API Boundary

java.io.File is older than NIO.

Modern Java APIs should usually prefer Path.

Path path = file.toPath();

File may still appear because:

  • legacy libraries use it
  • old APIs require it
  • migration is incremental

For new API design, prefer:

void importFile(Path path)

over:

void importFile(File file)

Path better represents provider-backed filesystems and composes with Files, FileSystem, and NIO.


7. InputStream as an API Boundary

Use InputStream when the method consumes a sequential byte source that the caller already opened.

Document parse(InputStream input) throws IOException;

This means:

  • method reads bytes sequentially
  • input is probably one-shot
  • caller usually owns closing unless documented otherwise
  • method should not assume replayability
  • method should not read beyond its boundary contract

7.1 Ownership Contract

Bad API because ownership is unclear:

void parse(InputStream input);

Better documentation:

/**
 * Parses a document from {@code input}.
 * The caller remains responsible for closing {@code input}.
 * This method consumes bytes until EOF.
 */
Document parse(InputStream input) throws IOException;

Alternative if method owns close:

/**
 * Parses and closes {@code input} before returning.
 */
Document parseAndClose(InputStream input) throws IOException;

But be careful: surprising close behavior can break callers.

7.2 Good Use Cases

Use InputStream for:

  • HTTP request body
  • socket stream
  • process stdout
  • uploaded file stream
  • in-memory source wrapped as ByteArrayInputStream
  • compressed stream input
  • generic byte parser

7.3 Bad Use Cases

Avoid raw InputStream if the method needs:

  • file metadata
  • seek/random access
  • multiple passes
  • reliable retry
  • file locking
  • atomic file operations
  • known file identity

In those cases, prefer Path or SeekableByteChannel.


8. OutputStream as an API Boundary

Use OutputStream when the method produces sequential bytes into a caller-provided sink.

void writeReport(OutputStream output) throws IOException;

This means:

  • method writes bytes
  • caller likely owns close
  • method may flush if required by contract
  • method should not close unless explicitly stated
  • partial output may exist on failure

8.1 Should the Method Flush?

Usually:

  • library methods should not close caller-owned streams
  • flushing may be acceptable at a logical boundary
  • excessive flushing hurts performance

Example:

void writeFrame(OutputStream out, Frame frame) throws IOException {
    writeHeader(out, frame);
    writeBody(out, frame);
    // no close
}

If frame visibility requires flush:

void writeFrameAndFlush(OutputStream out, Frame frame) throws IOException {
    writeFrame(out, frame);
    out.flush();
}

Make it explicit in the method name or documentation.


9. Reader and Writer Boundaries

Use Reader/Writer when bytes have already crossed the charset boundary.

Config parseConfig(Reader reader) throws IOException;

This API says:

  • parser operates on characters
  • caller controls charset decoding
  • parser does not know original bytes
  • parser cannot validate byte-level encoding details

Use Reader when the format is text and the caller should choose charset.

Use Path + Charset when the parser should own opening and decoding.

Config parseConfig(Path path, Charset charset) throws IOException;

Avoid APIs that silently use the platform default charset.

Bad:

new FileReader(file)

Better:

Files.newBufferedReader(path, StandardCharsets.UTF_8)

9.1 Good Use Cases

Use Reader for:

  • text parsers
  • templating engines
  • line-based readers
  • CSV-like parsers where caller owns decoding

Use Writer for:

  • text report generation
  • template rendering
  • structured text output

9.2 Boundary Caution

Once you accept Reader, you cannot:

  • compute checksum of original bytes
  • inspect BOM reliably
  • recover exact original bytes
  • apply byte-level limits unless caller enforces them

This is not bad. It is a contract.


10. byte[] as an API Boundary

Use byte[] when the payload is already materialized and bounded.

Message parse(byte[] bytes);

This is good for:

  • small payloads
  • cryptographic digests
  • tests
  • protocol frames with known maximum size
  • immutable-ish input values
  • in-memory caches

But byte[] communicates:

The whole payload is already in memory.

Do not use byte[] as the only API for potentially large data.

Bad:

void upload(byte[] file);

Better:

void upload(InputStream file, long contentLength);

or:

void upload(Path file);

10.1 Defensive Copying

Arrays are mutable.

If storing a byte[], consider copying:

record Blob(byte[] bytes) {
    Blob {
        bytes = bytes.clone();
    }

    @Override
    public byte[] bytes() {
        return bytes.clone();
    }
}

For high-performance code, copying may be too expensive, but then ownership must be explicit.


11. String as an API Boundary

Use String only after text decoding and full materialization are intended.

Template compile(String source);

Good for:

  • small config snippets
  • SQL/query templates
  • test fixtures
  • small JSON/XML snippets already decoded

Bad for:

  • huge files
  • unknown-size request bodies
  • binary data
  • text where encoding errors matter

A String has no original charset, no original newline bytes, and no BOM.


12. ByteBuffer as an API Boundary

Use ByteBuffer when you need NIO-style buffer semantics:

  • position/limit/capacity
  • direct buffer compatibility
  • channel IO
  • binary parsing
  • slicing
  • zero-copy-ish views
  • interop with APIs that expect buffers

Example:

int parseFrame(ByteBuffer buffer);

But ByteBuffer APIs are easy to design poorly because position is mutable.

12.1 State-Mutating Contract

This method consumes bytes by advancing position:

Frame readFrame(ByteBuffer buffer);

Document:

On success, buffer position is advanced past the frame. On failure, position is unspecified.

Or stronger:

On failure, buffer position is restored.

But restoring position requires deliberate code.

12.2 Non-Mutating Contract

Use duplicate/slice for read-only parsing:

Frame peekFrame(ByteBuffer buffer) {
    ByteBuffer view = buffer.asReadOnlyBuffer();
    return parseWithoutMutatingCallerPosition(view);
}

12.3 ByteBuffer Ownership Matrix

API ContractMeaning
borrows buffer during callmethod must not retain reference
consumes buffer positioncaller expects position mutation
retains buffercaller must not mutate after call
copies buffersafe but costs memory
returns sliceshared content, independent position/limit

This must be explicit.


13. ReadableByteChannel and WritableByteChannel

Use channel interfaces when the API is byte-oriented and may integrate with NIO.

long transfer(ReadableByteChannel source, WritableByteChannel sink) throws IOException;

Good for:

  • NIO ecosystem
  • ByteBuffer-based loops
  • non-stream source/sink abstraction
  • adapters via Channels.newChannel

Channel reads/writes may be partial.

while (buffer.hasRemaining()) {
    sink.write(buffer);
}

For blocking channels this eventually drains or fails. For non-blocking channels, a zero write may require readiness management.

Do not design a channel API unless your implementation handles channel semantics correctly.


14. SeekableByteChannel and FileChannel

Use SeekableByteChannel when random access matters but you do not need FileChannel specifics.

Index readIndex(SeekableByteChannel channel) throws IOException;

Use FileChannel when you need:

  • file locking
  • memory mapping
  • force
  • transferTo/transferFrom
  • file-specific operations
  • positional read/write with stronger file semantics

Example:

void writeSegment(FileChannel channel, long offset, ByteBuffer data) throws IOException;

This contract is precise:

  • target is a file channel
  • operation writes at a position
  • caller owns channel lifecycle
  • caller controls durability unless method calls force

15. Supplier<InputStream> for Replayable Data

Use Supplier<InputStream> when an operation may need to read the data more than once.

void upload(Supplier<? extends InputStream> bodySupplier) throws IOException;

But Supplier<InputStream> is incomplete unless documented.

Required contract:

/**
 * The supplier must return a new stream positioned at the beginning for each call.
 * The caller remains responsible for ensuring the underlying content is stable
 * across retries.
 */
void upload(Supplier<? extends InputStream> bodySupplier) throws IOException;

Good use cases:

  • retryable uploads
  • checksum before transfer
  • content-length calculation plus send
  • signing then sending
  • parsers needing multiple passes

Bad use:

InputStream in = Files.newInputStream(path);
Supplier<InputStream> bad = () -> in;

That returns the same consumed stream.

Good:

Supplier<InputStream> good = () -> Files.newInputStream(path);

16. Callback-Based APIs

Callbacks let the callee own resource lifecycle.

static <T> T withInputStream(Path path, IOFunction<InputStream, T> action) throws IOException {
    try (InputStream in = Files.newInputStream(path)) {
        return action.apply(in);
    }
}

@FunctionalInterface
interface IOFunction<T, R> {
    R apply(T value) throws IOException;
}

Usage:

long size = withInputStream(path, in -> in.transferTo(OutputStream.nullOutputStream()));

Benefits:

  • resource cannot escape accidentally
  • lifecycle is clear
  • method controls open options
  • caller provides behavior

Costs:

  • more abstract
  • harder to compose with some frameworks
  • callback must not retain resource

Document:

The input stream is valid only during callback execution.


17. Visitor / Handler APIs for Records

For record-oriented streaming, do not expose List<Record> if records may be huge.

Bad:

List<Record> parse(Path path) throws IOException;

Better:

void parse(Path path, RecordHandler handler) throws IOException;

@FunctionalInterface
interface RecordHandler {
    void onRecord(Record record) throws IOException;
}

This allows streaming processing.

But handler APIs need failure behavior:

  • If handler throws, does parsing stop?
  • Is source closed?
  • Is partial output kept?
  • Can handler request cancellation without exception?

A richer interface:

interface RecordHandler {
    ProcessingDecision onRecord(Record record) throws IOException;
}

enum ProcessingDecision {
    CONTINUE,
    STOP
}

This makes early termination explicit.


18. Java Stream<T> as Boundary

Returning Stream<T> can be elegant but dangerous for IO-backed data.

Stream<Record> records(Path path) throws IOException;

Problems:

  • stream must be closed
  • exceptions inside stream pipelines are awkward
  • resource lifetime crosses method boundary
  • caller may process lazily after surrounding context ends
  • parallel stream may be unsafe

If you return an IO-backed Java stream, document closure:

/**
 * Returns a lazily read stream of records. The caller must close the stream.
 */
Stream<Record> records(Path path) throws IOException;

Usage must be:

try (Stream<Record> records = reader.records(path)) {
    records.forEach(this::process);
}

For internal platform APIs, callback style is often safer.


19. Content Length and Metadata

Sometimes data alone is insufficient.

An input stream has no inherent size.

Avoid APIs that need size but accept only InputStream.

Bad:

void upload(InputStream body);

Better:

void upload(InputStream body, OptionalLong contentLength);

or:

record BodySource(
        Supplier<? extends InputStream> openStream,
        OptionalLong contentLength,
        String contentType
) {}

For file-backed content:

record FileBody(Path path, long size, String contentType) {}

Do not pretend metadata does not exist. Model it explicitly.


20. Designing a Body Abstraction

For complex systems, a small source abstraction can be better than raw Java types.

interface BodySource {
    InputStream openStream() throws IOException;
    OptionalLong contentLength();
    boolean replayable();
}

File implementation:

record PathBodySource(Path path) implements BodySource {
    @Override
    public InputStream openStream() throws IOException {
        return Files.newInputStream(path);
    }

    @Override
    public OptionalLong contentLength() {
        try {
            return OptionalLong.of(Files.size(path));
        } catch (IOException e) {
            return OptionalLong.empty();
        }
    }

    @Override
    public boolean replayable() {
        return true;
    }
}

One-shot implementation:

final class OneShotBodySource implements BodySource {
    private InputStream input;

    OneShotBodySource(InputStream input) {
        this.input = Objects.requireNonNull(input);
    }

    @Override
    public synchronized InputStream openStream() throws IOException {
        if (input == null) {
            throw new IOException("body source already consumed");
        }
        InputStream result = input;
        input = null;
        return result;
    }

    @Override
    public OptionalLong contentLength() {
        return OptionalLong.empty();
    }

    @Override
    public boolean replayable() {
        return false;
    }
}

This is more verbose, but it makes retry semantics explicit.


21. Designing a Sink Abstraction

Similarly, a sink may need metadata and commit behavior.

interface BodySink {
    OutputStream openStream() throws IOException;
    void commit() throws IOException;
    void abort() throws IOException;
}

This is useful when output should be staged before becoming visible.

Example contract:

openStream -> write bytes -> close stream -> commit
failure before commit -> abort

This is better than handing out a raw OutputStream when atomic visibility matters.


22. Naming Matters

IO API names should reveal lifecycle and materialization.

NameExpected Meaning
readAllmay materialize everything
streamincremental/lazy, likely requires close
copytransfers bytes, may not close
writeTowrites to caller-owned sink
openreturns resource caller must close
withcallback owns temporary resource scope
parseconsumes input, produces structured result
forEachstreaming callback, no full collection
loadoften full materialization
savemay own destination lifecycle

Bad naming creates wrong mental models.

Example:

byte[] streamFile(Path path)

This name is contradictory.


23. Exception Design

IO APIs should not erase IOException unless there is a clear abstraction reason.

Good low-level API:

void writeTo(OutputStream out) throws IOException;

Domain-level API may wrap:

try {
    repository.store(body);
} catch (IOException e) {
    throw new DocumentStorageException("failed to store document " + id, e);
}

Rules:

  1. Preserve cause.
  2. Add boundary context.
  3. Do not swallow cleanup failure silently if it matters.
  4. Avoid vague unchecked wrappers at low levels.
  5. Separate validation failure from transport/storage failure.

Example:

throw new IOException("failed while writing invoice export to " + target, e);

This is much better than:

throw new IOException("failed");

24. Blocking Semantics in API Design

Make blocking behavior visible.

Bad:

void send(Event event);

Does this block on network IO? Disk? Queue?

Better:

void writeEvent(Event event) throws IOException;

or:

CompletionStage<Void> writeEventAsync(Event event);

If an API is blocking, make it acceptable by contract.

If async, define:

  • executor ownership
  • cancellation semantics
  • buffer ownership after call
  • when resource can be closed
  • how exceptions are surfaced

Async IO with unclear ownership is worse than blocking IO with clear ownership.


25. API Boundary Matrix

RequirementPrefer
Callee owns file lifecyclePath
Caller owns opened byte sourceInputStream
Caller owns opened text sourceReader
Small bounded binary payloadbyte[]
Small bounded text payloadString
Random accessSeekableByteChannel / FileChannel
Positional file writesFileChannel
Retryable streaming sourceSupplier<InputStream> or custom BodySource
Callee owns temporary resource scopecallback / withX method
Large record processinghandler/visitor callback
IO-backed lazy record streamStream<T> with explicit close contract
ByteBuffer-native parserByteBuffer with position contract
Atomic output visibilitycustom sink abstraction / Path staging

26. Case Study: Parser API

Suppose we need to parse a custom binary document format.

Naive API:

Document parse(byte[] bytes);

This forces full materialization.

Better set:

Document parse(InputStream in) throws IOException;
Document parse(Path path) throws IOException;

Implementation:

Document parse(Path path) throws IOException {
    try (InputStream in = new BufferedInputStream(Files.newInputStream(path))) {
        return parse(in);
    }
}

This gives callers flexibility.

If the parser needs random access:

Document parse(SeekableByteChannel channel) throws IOException;

Then the Path overload can open a FileChannel.

Document parse(Path path) throws IOException {
    try (SeekableByteChannel channel = Files.newByteChannel(path, StandardOpenOption.READ)) {
        return parse(channel);
    }
}

Design principle:

Provide convenience overloads around one precise core abstraction.


27. Case Study: Export API

Suppose a service generates large CSV exports.

Bad:

String exportCsv(Query query);

This materializes the entire export as a string.

Better:

void writeCsv(Query query, Writer writer) throws IOException;

Convenience:

void writeCsv(Query query, Path path, Charset charset) throws IOException {
    try (BufferedWriter writer = Files.newBufferedWriter(path, charset)) {
        writeCsv(query, writer);
    }
}

For HTTP frameworks:

void writeCsv(Query query, OutputStream out) throws IOException {
    Writer writer = new BufferedWriter(new OutputStreamWriter(out, StandardCharsets.UTF_8));
    writeCsv(query, writer);
    writer.flush();
}

Do not close framework-owned out unless the framework contract says to.


28. Case Study: Upload API With Retry

Bad:

void upload(InputStream body) throws IOException;

If upload fails after partial send, retry is impossible.

Better:

void upload(BodySource body) throws IOException;

The implementation can check:

if (!body.replayable() && retryPolicy.maxAttempts() > 1) {
    throw new IllegalArgumentException("retry requires replayable body");
}

This prevents impossible behavior from being discovered during an outage.


29. Case Study: Ingestion API With Atomic File Visibility

Bad:

OutputStream openFinalOutput(String objectName) throws IOException;

This exposes partial final output if the writer fails halfway.

Better:

interface StagedWrite {
    OutputStream openStream() throws IOException;
    void commit() throws IOException;
    void abort() throws IOException;
}

Usage:

StagedWrite write = store.stage(objectName);
boolean committed = false;
try (OutputStream out = write.openStream()) {
    source.transferTo(out);
    committed = true;
}

if (committed) {
    write.commit();
} else {
    write.abort();
}

Production version should handle exceptions more carefully, but the state model is clear.


30. Overload Strategy

Good IO APIs often provide layered overloads.

Example:

public Document parse(Path path) throws IOException;
public Document parse(InputStream input) throws IOException;
private Document parse(DocumentInput input) throws IOException;

But too many overloads can confuse ownership.

Guidelines:

  1. Choose one core abstraction.
  2. Add convenience overloads only when they clarify lifecycle.
  3. Keep close behavior consistent.
  4. Avoid overloads that differ only subtly in ownership.
  5. Document whether streams are closed.

Bad overload pair:

parse(InputStream in);       // does not close
parse(FileInputStream in);   // closes

This is surprising.


31. Resource Ownership Documentation Template

Use this template in JavaDoc for IO APIs:

/**
 * Writes the encoded document to {@code output}.
 *
 * <p>Resource ownership: this method does not close {@code output}.
 * It may call {@link OutputStream#flush()} after writing a complete document.
 *
 * <p>Partial output: if an exception is thrown, {@code output} may contain
 * a partial document.
 *
 * <p>Threading: this method is blocking and must not be called on an event-loop thread.
 *
 * @throws IOException if reading, encoding, or writing fails
 */
void writeTo(OutputStream output) throws IOException;

This is not bureaucracy. It prevents production ambiguity.


32. Anti-Patterns

32.1 Ambiguous Close Behavior

void process(InputStream in)

No one knows who closes it.

32.2 Full Materialization by Default

byte[] generateHugeReport()

32.3 Charset Hidden Inside Method

Config parse(InputStream in) // silently uses default charset

32.4 One-Shot Source With Retry Policy

upload(InputStream in, RetryPolicy retry)

Retry may be impossible.

32.5 Mutable ByteBuffer Retained Without Ownership

void enqueue(ByteBuffer buffer)

Can caller mutate after enqueue?

32.6 Java Stream Returned Without Close Contract

Stream<Record> records()

May leak an open file.

32.7 Path API That Only Needs Bytes

parse(Path path)

If the parser does not need filesystem semantics, also provide InputStream.


33. Design Checklist

For each IO API, specify:

  1. Is the data binary or text?
  2. Is size bounded?
  3. Is payload materialized or streamed?
  4. Who opens the resource?
  5. Who closes the resource?
  6. Is data one-shot or replayable?
  7. Can the method block?
  8. Can it be cancelled?
  9. Is timeout supported?
  10. Is output partial on failure?
  11. Does the method flush?
  12. Does the method call force or provide durability?
  13. Is charset explicit?
  14. Does API need metadata?
  15. Does API need random access?
  16. Does API mutate ByteBuffer position?
  17. Can the object escape the method scope?
  18. What exceptions are thrown?
  19. What is safe to retry?
  20. What is the maximum memory usage?

34. Practice Lab: Redesign Bad IO APIs

Redesign these APIs.

34.1 Bad API A

byte[] download(String id);

Questions:

  • Is the object always small?
  • Should caller stream to file?
  • Does API need metadata?
  • Can download be resumed?

Potential redesign:

void downloadTo(String id, OutputStream sink) throws IOException;
BodySource openDownload(String id) throws IOException;

34.2 Bad API B

void importUsers(String csv);

Potential redesign:

ImportResult importUsers(Reader reader) throws IOException;
ImportResult importUsers(Path path, Charset charset) throws IOException;

34.3 Bad API C

void upload(InputStream input, boolean retry);

Potential redesign:

void upload(BodySource source, RetryPolicy retryPolicy) throws IOException;

34.4 Bad API D

Stream<String> readLines(Path path);

Potential redesign:

void forEachLine(Path path, Charset charset, IOConsumer<String> consumer) throws IOException;

or document close clearly:

Stream<String> openLines(Path path, Charset charset) throws IOException;

35. Mental Model Summary

Choosing an IO API type is choosing a boundary contract.

Use:

  • Path when file identity/lifecycle matters.
  • InputStream for one-shot sequential bytes.
  • OutputStream for caller-owned sequential byte sinks.
  • Reader/Writer after charset boundary.
  • byte[]/String for small bounded materialized payloads.
  • ByteBuffer when position/limit/channel interop matters.
  • Channel when NIO semantics matter.
  • Supplier<InputStream> or custom source when replayability matters.
  • callbacks when callee should own resource scope.
  • custom source/sink abstractions when metadata, commit, retry, or lifecycle is part of the domain.

The strongest API designs make impossible states hard to express.

A weak API says:

void process(InputStream input);

A strong API says:

void processReplayable(BodySource source, ProcessingPolicy policy) throws IOException;

or:

void process(Path path, Charset charset) throws IOException;

depending on what the operation truly needs.

Good IO API design is not about using the fanciest abstraction.

It is about making ownership, lifetime, size, retry, blocking, encoding, and partial failure explicit.


36. References

Lesson Recap

You just completed lesson 22 in deepen practice. 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.