JavaRush /Courses /Go SELF /Injecting io.Writer/ io.Reader instead of direct stdout/...

Injecting io.Writer/ io.Reader instead of direct stdout/stdin

Go SELF
Level 53, Lesson 0
Available

1. Why avoid direct os.Stdout/os.Stdin in a CLI

When you write your first CLI, your hand naturally reaches for fmt.Println(...), and then for fmt.Scan(...). That is a normal stage of growing up: first “as long as it works,” then “why can’t I test this properly?”. The problem is not fmt; the problem is that by default fmt writes to the process-wide global stdout and reads from the process-wide global stdin.

Imagine you want to test a command automatically: pass arguments, get output, compare it to the expected result. If the command prints directly to os.Stdout, the test has to “capture” the global stdout of the whole process (and pray parallel tests do not create a mess). And if the command reads from os.Stdin, the test must replace stdin (and again you get global state and fragility). In the end, tests stop being “logic verification” and turn into an exorcism session.

I/O injection (passing io.Reader and io.Writer as parameters) solves this calmly, the Go way: the command becomes a regular function that reads from the provided source and writes to the provided sink.

Anti-example: “everything directly”

First, let’s look at code that seems logical to a beginner, but creates problems later. This example is not “bad”; it is simply “early.”

package main

import (
	"fmt"
	"os"
)

func main() {
	fmt.Println("todo-cli") // writes to the process stdout
	fmt.Fscan(os.Stdin, new(string))
}

From the outside everything is fine: the program prints a greeting, then reads something from stdin. But now try to answer: “how do I test that it prints exactly todo-cli\n?”. You will have to capture the stdout of the whole process, and that is no longer about logic, but about acrobatics.

We want our logic to be able to work with any io.Reader/io.Writer, while os.Stdin/os.Stdout are only one option that is wired up at the boundary—in main.

2. What io.Reader and io.Writer are

io.Reader and io.Writer are two very small interfaces from the standard library that define the idea of a data “stream.” A Reader reads bytes; a Writer accepts bytes. What matters is not even that they are bytes, but that this is a contract you can plug anything into: a file, the network, an in-memory buffer, a string, a logger, a gzip wrapper—anything that can read/write.

Why does Go love this idea so much? Because it makes code flexible without magic and without frameworks. In official Go style materials, you will repeatedly see the idea “design around interfaces,” and io.Writer is one of the most popular examples: it is easy to layer behavior on top without changing the calling code.

For a CLI this is especially convenient: stdout is just an io.Writer. Stdin is just an io.Reader. Which means: if we pass them into the command, the command stops depending on a real terminal and becomes testable.

3. The command contract: Run(...) int and a thin main

The command as a Run function

Now we introduce today’s central idea: the core of a CLI should be a function that takes arguments and I/O as dependencies, and returns an exit code. This contract is simple but powerful: it removes global dependencies and makes behavior predictable.

Typically the signature looks like this:


Run(args []string, in io.Reader, out, errOut io.Writer) int

What matters here is that out and errOut are separated. stdout and stderr are not “two names for the same thing,” but two different semantic channels: one for results, the other for problems and hints. And it is also important that Run does not call os.Exit—because os.Exit kills the process instantly, and the test will not be able to verify anything afterward.

A minimal scaffold for our todo-cli:

package cli

import (
	"fmt"
	"io"
)

func Run(args []string, in io.Reader, out, errOut io.Writer) int {
	_ = in // not used yet
	fmt.Fprintln(out, "todo-cli") // todo-cli
	return 0
}

Yes, this is boring for now. But now Run can be called from main, from a test, or from other code. And every time, the output will go exactly where we say.

main as a thin wrapper

It is very easy to accidentally turn main into a huge function where everything is mixed together. But for a CLI it is cleaner to keep main thin: it should only connect the “real wires” (args, stdin/stdout/stderr) and call our core.

Example main.go:

package main

import (
	"os"

	"example.com/todo/cli"
)

func main() {
	code := cli.Run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr)
	os.Exit(code)
}

Notice a funny thing: os.Exit is acceptable here and even useful—this is the application boundary. We are not trying to “ban os.Exit entirely”; we just do not let it leak into the command’s business logic.

Exit codes as part of the contract

It may feel like the exit code is a separate topic, but in practice it goes hand-in-hand with I/O injection. When you make a command a Run function, it cannot “just terminate however it wants”: it must return a result, and that result should be standardized.

To avoid magic numbers, define constants:

package cli

const (
	exitOK    = 0
	exitFail  = 1
	exitUsage = 2
)

And use them in code:

package cli

import (
	"fmt"
	"io"
)

func Run(args []string, in io.Reader, out, errOut io.Writer) int {
	_ = in

	if len(args) == 0 {
		fmt.Fprintln(errOut, "missing command") // missing command
		return exitUsage
	}

	fmt.Fprintln(out, "ok") // ok
	return exitOK
}

Pay attention to the discipline: the error message went to errOut, while the “successful result” went to out. This becomes critical when people start using your app in scripts: stdout can be parsed, stderr can be read by humans.

Rule: results go to stdout, diagnostics go to stderr

Here is a small rule that may sound pedantic, but saves hours of debugging. When you write a CLI, mentally classify every output line: is it a result, or is it diagnostics? Then print to the appropriate writer.

If later you want to implement todo-cli list and redirect the result to a file, the user will do this:

todo-cli list > tasks.txt

And they expect tasks.txt to contain only the task list, not “error: could not open file” or “hint: use the --help flag”. Therefore, errors and usage should always go to stderr.

Let’s show this on a simple command dispatcher:

package cli

import (
	"fmt"
	"io"
)

func Run(args []string, in io.Reader, out, errOut io.Writer) int {
	_ = in

	switch {
	case len(args) == 0:
		fmt.Fprintln(errOut, "missing command") // missing command
		return exitUsage
	case args[0] == "version":
		fmt.Fprintln(out, "todo-cli dev") // todo-cli dev
		return exitOK
	default:
		fmt.Fprintf(errOut, "unknown command: %s\n", args[0]) // unknown command: ...
		return exitUsage
	}
}

4. Injecting stdin and controlling flag output

Injecting stdin: read from in

With stdout, things are usually clear: “don’t print directly; print to a writer.” With stdin, beginners often hit a small mental glitch: “how do I read a string now?”.

Answer: the same way as before, only from a different source. Most input functions in Go can work not only with stdin, but with any io.Reader. For example, fmt.Fscan takes a reader as its first argument—and that is exactly our case.

Suppose we want an echo command that reads one word from input and prints it (a toy example, but it shows the mechanics):

package cli

import (
	"fmt"
	"io"
)

func runEcho(in io.Reader, out, errOut io.Writer) int {
	var s string
	if _, err := fmt.Fscan(in, &s); err != nil {
		fmt.Fprintln(errOut, "failed to read input") // failed to read input
		return exitUsage
	}
	fmt.Fprintln(out, s) // prints the word that was read
	return exitOK
}

The key point: we read from in, not from os.Stdin. In the real main we will pass os.Stdin, and in a test we can pass a string via strings.NewReader("hello"). Even without thinking about tests, you can already see the function became more universal.

flag.FlagSet: configure output via SetOutput

There is another annoying detail that breaks CLI testability: the standard flag package also likes to write “somewhere” by default. So when you create a subcommand with flags, it is better not to use the global flag.Parse(), but to create a separate FlagSet and configure its output.

That is, you do not just call flag.NewFlagSet; you also call SetOutput(errOut). This is again about I/O injection: we do not let internals print wherever they feel like.

Mini example of a list command with a -limit flag:

package cli

import (
	"flag"
	"fmt"
	"io"
)

func runList(args []string, out, errOut io.Writer) int {
	fs := flag.NewFlagSet("list", flag.ContinueOnError)
	fs.SetOutput(errOut)

	limit := fs.Int("limit", 10, "max items")
	if err := fs.Parse(args); err != nil {
		return exitUsage
	}

	fmt.Fprintf(out, "limit=%d\n", *limit) // limit=10
	return exitOK
}

What matters here is that even flag parsing errors will go to errOut, which we control. This makes behavior predictable both in a terminal and in automated checks.

5. How data flows, and why this is better than direct os.Stdout/os.Stdin

Mini diagram of the data flow

Sometimes understanding improves dramatically when you can see the “wires” explicitly. In our design, main is where real system streams get connected, and cli.Run is the logic that knows nothing about the OS.

flowchart LR
    A[main.go] -->|args| B[cli.Run]
    A -->|os.Stdin| B
    B -->|out io.Writer| C[os.Stdout]
    B -->|errOut io.Writer| D[os.Stderr]
    B -->|exit code| A

This approach is exactly what makes a CLI into code you can run anywhere: in a terminal, in tests, or inside another application as a library (yes, that happens sometimes).

Table: before and after

To reinforce the idea, let’s summarize it in a table. This is useful because visually it is obvious: we are not making the logic more complex; we are simply moving dependencies to function inputs.

Task “Directly” (bad for tests) “With injection” (good)
Print a result
fmt.Println(...)
fmt.Fprintln(out, ...)
Print an error/usage
fmt.Fprintln(os.Stderr, ...)
fmt.Fprintln(errOut, ...)
Read input
fmt.Fscan(os.Stdin, &x)
fmt.Fscan(in, &x)
Terminate the program
os.Exit(...)
anywhere
return code
from the logic,
os.Exit
only in
main

6. Common mistakes when injecting io.Reader/io.Writer

Mistake #1: inside the command they still print to os.Stdout “out of habit”.
This often happens when you create a “nice” signature like Run(... out io.Writer ...), but then somewhere deep down you leave a fmt.Println or fmt.Fprintln(os.Stdout, ...). As a result, half the output goes into the test buffer, and half goes to the real terminal, and you get mystery-level behavior like “why does it print twice?”. The fix is simple: print only via out/errOut, and in your IDE you can search the project for os.Stdout and fmt.Print.

Mistake #2: mixing stdout and stderr into a single stream.
Sometimes it feels like “who cares, the user sees the text anyway.” The difference shows up the moment someone uses a pipe: todo-cli list | grep ... or a redirect > file. If errors and hints end up in stdout, they break machine processing of the result. The habit of separating channels from day one saves a lot of time and makes the CLI more professional.

Mistake #3: Run calls os.Exit and thereby kills the test (and defer).
This is not only about tests: os.Exit terminates the process without running deferred defer calls, which means you might not close a file, flush a buffer, or finish writing data. The contract must be ironclad: the logic returns a code, and main decides when to call os.Exit.

Mistake #4: flag prints messages “somewhere” on its own because you did not configure SetOutput.
Even if you are disciplined and print everything to errOut, FlagSet may, by default, print flag parsing errors somewhere other than where you expected. That is why each subcommand with flags should have its own FlagSet, and it should have fs.SetOutput(errOut)—this is part of the same I/O injection discipline.

Mistake #5: making output nondeterministic and then being surprised that it is “hard to compare.”
When you start comparing output in tests, it suddenly matters that lines must match exactly, including the trailing \n. If one branch prints with a newline and another without, the behavior becomes “almost the same,” but the test (and user scripts) will not agree. Choose a style (usually each line ends with \n) and stick to it in all branches.

1
Task
Go SELF, level 53, lesson 0
Locked
Application banner
Application banner
1
Task
Go SELF, level 53, lesson 0
Locked
Greeting Streams
Greeting Streams
1
Task
Go SELF, level 53, lesson 0
Locked
Command dispatcher
Command dispatcher
1
Task
Go SELF, level 53, lesson 0
Locked
Repeat with flags
Repeat with flags
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION