JavaRush /Courses /Go SELF /CLI architecture: parse → app layer → render output

CLI architecture: parse → app layer → render output

Go SELF
Level 48, Lesson 4
Available

1. Introduction

It can feel like a CLI is a tiny program: “come on, read the flags, compute something, print it.” And that’s true… right up until you add a second flag, a third check, a fourth output format, and suddenly your main() starts looking like a box of wires you’re afraid to touch because “last time, everything crashed after the edit.”

The problem is not flag, and it’s not Go. The problem is mixing responsibilities: when argument parsing, business logic, error handling, and output formatting all live in one place, any change pulls on everything at once. That’s why we want a simple, “human” pipeline: first parse the input, then do the action, then neatly present the result.

2. The CLI pipeline: parse → app → render

If you want to remember this lecture with a single picture, it’s a pipeline. We have an input in the form of command-line arguments, an output as text (or data), and in between, clear stages. It’s like a kitchen workflow: first wash and prep the ingredients, then cook, then plate it nicely. If you mix everything into one step, you get a “soup of raw pasta” (yes, that is also possible).

Here is the diagram we will aim for:

flowchart LR
    A["args: os.Args / flag"] --> B["parse: Config + validate"]
    B --> C["app-layer: perform the action"]
    C --> D["render: print the result (stdout)"]
    B --> E["contract error (stderr)"]
    C --> F["runtime/internal error (stderr)"]

Important: “layers” here do not mean “five packages and a DI container.” It’s just an agreement on how we split code into functions so it’s easier for the brain.

Layer contract: Config and the result

When you build a pipeline, you need to decide what exactly “moves along the conveyor belt.” In a CLI, that is usually the run configuration: already-parsed and validated values (strings, numbers, mode). And there is a small but very important habit: deeper inside the program, we pass values, not “pointers to flags.”

Why? Because flag.String("...") returns *string, and that’s convenient for flag itself, but inconvenient for your logic. The app layer should not know that “somewhere there were flags”; it should receive normal data: "buy milk" and 3, not *title and *priority.

Let’s start our training CLI as a “mini todo”: the command accepts a mode ("add" or "list") and, if we are adding a task, a title and priority.


package main

type Config struct {
	Mode     string // "add" or "list"
	Title    string // task title (for add)
	Priority int    // priority 1..5 (for add)
}

This struct is our “contract” from the parser to the app layer: everything is already converted to types and validated.

3. Parsing and validating arguments

The argument parser is where there’s a lot of “user mess”: someone didn’t pass -title, someone wrote -priority=999, someone ran the program without understanding what they want. In this area, it’s normal to be strict and boring: validate the input and return a clear error. But printing something “as the program result” is not a good idea here: the parser should either return Config or return error.

Let’s implement parseArgs() () (Config, error). We’ll use the global functions you already know: flag.String/flag.Int and flag.Parse()—no extra complexity.

package main

import (
	"flag"
	"fmt"
)

func parseArgs() (Config, error) {
	mode := flag.String("mode", "add", "mode: add|list")
	title := flag.String("title", "", "task title (required for add)")
	priority := flag.Int("priority", 1, "priority 1..5 (for add)")
	flag.Parse()

	if *mode != "add" && *mode != "list" {
		return Config{}, fmt.Errorf("unknown -mode: %s", *mode)
	}
	return Config{Mode: *mode, Title: *title, Priority: *priority}, nil
}

So far we only did “generic parsing.” Now let’s add semantic validation: -title is required for "add", and priority must be within range.

package main

import "fmt"

func validateConfig(cfg Config) error {
	if cfg.Mode == "add" && cfg.Title == "" {
		return fmt.Errorf("missing -title for mode=add")
	}
	if cfg.Mode == "add" && (cfg.Priority < 1 || cfg.Priority > 5) {
		return fmt.Errorf("-priority must be in range 1..5")
	}
	return nil
}

Notice the nuance: parseArgs() converts strings into types, while validateConfig() checks meaning. These are different jobs. The separation may seem small, but it greatly simplifies life when the number of rules grows.

4. App layer: business logic without output and without CLI knowledge

The app layer is where we “do the work.” There should be no reading of os.Args, no flag.Parse(), and (ideally) no printing to the console. Why? Because the app layer should work equally well from a CLI, from a test, or from a different interface. Today we have a CLI; tomorrow it’s something else. Even if “tomorrow” never comes, the separation still pays off in code cleanliness.

Let’s create a task model and the simplest app that stores tasks in memory (for a single run). Yes, after the program exits everything disappears—but right now we are practicing architecture, not databases.

package main

type Task struct {
	ID       int
	Title    string
	Priority int
	Done     bool
}

Now the app layer: the App struct and methods AddTask and ListTasks.

package main

type App struct {
	nextID int
	tasks  []Task
}

func NewApp() *App {
	return &App{nextID: 1}
}

The “add task” method:

package main

func (a *App) AddTask(title string, priority int) Task {
	t := Task{ID: a.nextID, Title: title, Priority: priority}
	a.nextID++
	a.tasks = append(a.tasks, t)
	return t
}

And the “list” method:

package main

func (a *App) ListTasks() []Task {
	return a.tasks
}

Note: there is no os.Stdout here, and no fmt.Println(). Only data and logic.

5. Render: printing to io.Writer

Once the program has done something, it needs to show it to the user. This is where the third layer appears: rendering. It’s the “presentation” of the result. In a CLI, it is usually text to stdout.

Why is it convenient to accept io.Writer? Because it’s the standard way in Go to abstract “where we write.” Today it is os.Stdout, and in other conditions it might be a file, a buffer, the network—it doesn’t matter. The idea is the same as in the standard library: functions accept a Writer and become reusable.

Let’s start by rendering one created task:

package main

import (
	"fmt"
	"io"
)

func renderCreatedTask(out io.Writer, t Task) {
	fmt.Fprintf(out, "created task #%d: %s (p=%d)\n", t.ID, t.Title, t.Priority)
	// created task #1: Buy milk (p=2)
}

Now render the task list. Let it be plain text—no tables, no “beauty”—but clear.

package main

import (
	"fmt"
	"io"
)

func renderTaskList(out io.Writer, tasks []Task) {
	if len(tasks) == 0 {
		fmt.Fprintln(out, "no tasks") // no tasks
		return
	}
	for _, t := range tasks {
		fmt.Fprintf(out, "#%d [%d] %s\n", t.ID, t.Priority, t.Title)
	}
}

Rendering is the place where it’s OK to think about formatting. But it should not decide “what to do”—it only displays what was already decided and done earlier.

6. Wiring it in main: a thin boundary and errors as values

main is the application boundary. It’s fine to do three things here: assemble the input (CLI), call the logic (app layer), output the result (render), and also handle errors and print them to stderr. In Go, errors are typically returned as values and checked explicitly; it’s part of the language philosophy: "errors are values". For CLI apps, this is especially convenient: you bubble the error up and print it exactly once, in one style.

Let’s build the wiring. For now, without custom usage text so the example stays compact.

package main

import (
	"fmt"
	"os"
)

func main() {
	cfg, err := parseArgs()
	if err != nil {
		fmt.Fprintln(os.Stderr, "error:", err)
		return
	}
	if err := validateConfig(cfg); err != nil {
		fmt.Fprintln(os.Stderr, "error:", err)
		return
	}

	app := NewApp()

	switch cfg.Mode {
	case "add":
		t := app.AddTask(cfg.Title, cfg.Priority)
		renderCreatedTask(os.Stdout, t)
	case "list":
		renderTaskList(os.Stdout, app.ListTasks())
	}
}

This main looks “boring.” And that’s a compliment. A boring main in the good sense is easy to read, easy to extend, and hard to break with an accidental change.

7. Complete example: main.go

When code is split into functions, you get the pleasant feeling that the program is not a “sheet,” but a set of parts. Below is a complete version of main.go that combines everything we assembled. I’ll keep it as a sequence of already-familiar pieces so you can see: no magic, just careful composition.

package main

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

type Config struct {
	Mode     string
	Title    string
	Priority int
}

type Task struct {
	ID       int
	Title    string
	Priority int
	Done     bool
}

type App struct {
	nextID int
	tasks  []Task
}

func NewApp() *App {
	return &App{nextID: 1}
}

func (a *App) AddTask(title string, priority int) Task {
	t := Task{ID: a.nextID, Title: title, Priority: priority}
	a.nextID++
	a.tasks = append(a.tasks, t)
	return t
}

func (a *App) ListTasks() []Task {
	return a.tasks
}

func parseArgs() (Config, error) {
	mode := flag.String("mode", "add", "mode: add|list")
	title := flag.String("title", "", "task title (required for add)")
	priority := flag.Int("priority", 1, "priority 1..5 (for add)")
	flag.Parse()

	if *mode != "add" && *mode != "list" {
		return Config{}, fmt.Errorf("unknown -mode: %s", *mode)
	}
	return Config{Mode: *mode, Title: *title, Priority: *priority}, nil
}

func validateConfig(cfg Config) error {
	if cfg.Mode == "add" && cfg.Title == "" {
		return fmt.Errorf("missing -title for mode=add")
	}
	if cfg.Mode == "add" && (cfg.Priority < 1 || cfg.Priority > 5) {
		return fmt.Errorf("-priority must be in range 1..5")
	}
	return nil
}

func renderCreatedTask(out io.Writer, t Task) {
	fmt.Fprintf(out, "created task #%d: %s (p=%d)\n", t.ID, t.Title, t.Priority)
}

func renderTaskList(out io.Writer, tasks []Task) {
	if len(tasks) == 0 {
		fmt.Fprintln(out, "no tasks")
		return
	}
	for _, t := range tasks {
		fmt.Fprintf(out, "#%d [%d] %s\n", t.ID, t.Priority, t.Title)
	}
}

func main() {
	cfg, err := parseArgs()
	if err != nil {
		fmt.Fprintln(os.Stderr, "error:", err)
		return
	}
	if err := validateConfig(cfg); err != nil {
		fmt.Fprintln(os.Stderr, "error:", err)
		return
	}

	app := NewApp()

	switch cfg.Mode {
	case "add":
		t := app.AddTask(cfg.Title, cfg.Priority)
		renderCreatedTask(os.Stdout, t)
	case "list":
		renderTaskList(os.Stdout, app.ListTasks())
	}
}

Yes, this is a “throwaway todo” that does not persist tasks between runs. But as a training stand, it’s ideal: you practice the right program shape, where each part does its job and doesn’t step into someone else’s responsibilities.

8. Common mistakes

Mistake #1: turning main into a “god function.”
It’s very easy to start with “five lines” and end up with main() at 200 lines: parsing, validation, computations, printing, branching, error handling. Usually this ends with you being afraid to add a new flag because you’re not sure you won’t break the old one. The cure is to separate parse/validate, the app layer, and rendering—at least into functions.

Mistake #2: dragging flag pointers (*string, *int) into business logic.
It may seem convenient to pass title *string deeper and dereference it there. But that quickly turns your code into a dependency on flag and makes it harder to understand: the function should work with a string, so why is it receiving a *string? It’s better to dereference once in the parser and pass normal values into the app layer.

Mistake #3: printing errors in multiple places.
A common scenario: validateConfig() prints a message, then main prints another one, then the renderer prints a third one. The user sees a mess, and you don’t know where exactly you should change the format. Better: agree that functions return error, and error printing is centralized at the application boundary (in main). This matches Go’s idea that errors are values you can pass up the stack.

Mistake #4: mixing diagnostics and results in stdout.
If you print both command results and error messages to stdout, it’s hard for users to use your program in pipes and scripts: they expect data in stdout and instead get a mix of “data + yelling.” The discipline is simple: results to stdout; errors and hints to stderr.

Mistake #5: validating “along the execution path” instead of immediately after parsing.
Sometimes code is structured like this: you do something, then discover that -title is empty, then do something else, then find a second error. This leads to partially executed actions and strange behavior. Much calmer: parse → validate → only then run the app layer.

1
Task
Go SELF, level 48, lesson 4
Locked
Pipeline Calculator
Pipeline Calculator
1
Task
Go SELF, level 48, lesson 4
Locked
Catalog search
Catalog search
1
Task
Go SELF, level 48, lesson 4
Locked
Numeric report
Numeric report
1
Survey/quiz
CLI: command design, 48 level, 4 lesson
Unavailable
CLI: command design
CLI: flag basics, command design
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION