JavaRush /Courses /Go SELF /Model invariants

Model invariants

Go SELF
Level 21, Lesson 4
Available

1. Why “just a struct” is not a model yet

When you create a struct for the first time, it’s really tempting to think: “That’s it, I have Task, so the task is always valid.” Unfortunately, reality is usually more sarcastic than the compiler. A struct is a container for fields, and a model is a container + rules. Those rules are called invariants.

What model invariants are, in plain words

An invariant is a condition that must be true for every “correct” value of your type. For example, if you have a task, then “an empty title” is almost certainly not a task—it’s sadness.

And here’s the key idea: the zero value of a struct is technically valid, but from the model’s perspective it can be garbage. Task{} compiles and exists, but that doesn’t mean you should store it in memory or show it to a user.

Imagine that a struct is a box, and invariants are the sticker: “fragile, this side up, do not drop.” A box without a sticker is still a box, but the chance of delivering shards is noticeably higher.

2. Invariants by example: a “Task” model for a mini‑CLI

So we’re not speaking abstractly, let’s agree on a tiny domain. Let’s say we’re building a learning console app “task list”: add a task, print the list, mark as done. We’re not making “perfect architecture”; what matters is learning to protect data.

Mini-model Task and its semantic rules

Let’s start simple: a task has an ID, a title, and a status. In Go, it’s natural to express this with a struct and a set of status constants.

package main

import "fmt"

type Status int

const (
	StatusTodo Status = iota + 1
	StatusDone
)

type Task struct {
	ID     int
	Title  string
	Status Status
}

func main() {
	t := Task{ID: 1, Title: "read about invariants", Status: StatusTodo}
	fmt.Println(t.ID, t.Title, t.Status) // 1 read about invariants 1
}

At this point you can already formulate invariants (correctness rules) without looking into “next topics” or building complicated stuff:

ID must be positive (ID > 0), the title must not be empty after TrimSpace, and the status must be only one of the allowed values (StatusTodo or StatusDone). It sounds trivial, but it’s exactly from such trivialities that “unexplainable bugs” grow.

A table: “invariant → why → where to check”

A bit of structure (sorry for the pun) helps the brain.

Invariant Why it’s needed Where to check
ID > 0
distinguish a “real” record from garbage and defaults when creating a task and when reading an ID from input
Title != ""
after
TrimSpace
an “empty task” is usually a user error when creating/updating the title
Status ∈ {StatusTodo, StatusDone}
otherwise the task state becomes unpredictable when creating and when changing the status

3. Validation “at the boundary”: where exactly to check data

Beginners often validate data “somewhere later.” It’s like the promise “I’ll wash the dishes later”: sometimes yes, but more often a new civilization appears in the sink. In programming it’s more convenient and reliable to validate at the boundary—at the moment data enters your system.

What counts as a “boundary”

In a console app, the boundary is user input (stdin) and any external data sources. Right now we’re working only with input, but the principle is universal: the earlier you check data, the fewer places in the program can “blow up.”

The pipeline looks like this:

flowchart TD
    A[stdin: user-provided string] --> B[parse: numbers/strings]
    B --> C[normalize: TrimSpace etc.]
    C --> D[validate: invariants]
    D --> E[create/execute: business logic]
    E --> F[stdout: result]

If validation happens after business logic, then business logic starts working with garbage. And business logic working with garbage usually starts behaving like garbage itself.

4. Validation patterns in Go: Validate and New

Now for the most practical part: how to structure checks. In Go, people love a simple contract: a function returns error, and nil means “everything is OK.” This continues the Go idea of “errors are values”: we create an error and return it upward if something is wrong.

Validation contract: ValidateX(x X) error

Let’s write a validation function. It either returns nil, or explains what exactly is wrong. Keep errors short and specific: that makes them easier to show to the user and to test.

package main

import (
	"errors"
	"strings"
)

type Status int

const (
	StatusTodo Status = iota + 1
	StatusDone
)

type Task struct {
	ID     int
	Title  string
	Status Status
}

func ValidateTask(t Task) error {
	if t.ID <= 0 {
		return errors.New("id must be positive")
	}
	if strings.TrimSpace(t.Title) == "" {
		return errors.New("title is empty")
	}
	if t.Status != StatusTodo && t.Status != StatusDone {
		return errors.New("unknown status")
	}
	return nil
}

func main() {}

Notice: we intentionally check the title via TrimSpace, because the string " " is technically non-empty, but semantically it’s empty.

Why error, not bool

Sometimes you want to write ValidateTask(t Task) bool and return true/false. But then you lose the answer to the question “why is it invalid?” In a real app, this turns into sad UX: “error” and that’s it. error lets you explain the reason, and the Go approach directly encourages you to add context when you return an error higher up.

The “create and validate” pattern: NewX(...)(X, error)

You can validate not only a finished struct, but also structure the creation process so that getting an “invalid task” accidentally is hard. For this, people often make a constructor function (not a method—just a function) that creates a value and validates it.

This is also a convenient place to do normalization (for example, TrimSpace) exactly once—and then never think about it elsewhere.

package main

import (
	"errors"
	"fmt"
	"strings"
)

type Status int

const (
	StatusTodo Status = iota + 1
	StatusDone
)

type Task struct {
	ID     int
	Title  string
	Status Status
}

func ValidateTask(t Task) error {
	if t.ID <= 0 {
		return errors.New("id must be positive")
	}
	if strings.TrimSpace(t.Title) == "" {
		return errors.New("title is empty")
	}
	if t.Status != StatusTodo && t.Status != StatusDone {
		return errors.New("unknown status")
	}
	return nil
}

func NewTask(id int, title string) (Task, error) {
	t := Task{
		ID:     id,
		Title: strings.TrimSpace(title),
		Status: StatusTodo,
	}
	if err := ValidateTask(t); err != nil {
		return Task{}, err
	}
	return t, nil
}

func main() {
	t, err := NewTask(1, "   Learn Go   ")
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	fmt.Printf("%+v\n", t) // {ID:1 Title:Learn Go Status:1}
}

Here’s an important point: if NewTask returns an error, we return Task{} (zero value) together with err. This disciplines the calling code: “if there’s an error—don’t touch the value, it’s empty.”

5. Validating user input: “read → parse → validate → build”

Now we have a model and its rules. The next step is to apply them at the boundary—i.e., at input time. For console tasks, the classic flow is: read a line, parse, validate.

Example: create a task from two lines of input

Suppose the user enters id and title. We read them as strings, parse id, then call NewTask.

package main

import (
	"bufio"
	"fmt"
	"os"
	"strconv"
	"strings"
)

type Status int

const (
	StatusTodo Status = iota + 1
	StatusDone
)

type Task struct {
	ID     int
	Title  string
	Status Status
}

func ValidateTask(t Task) error {
	if t.ID <= 0 {
		return fmt.Errorf("id must be positive")
	}
	if strings.TrimSpace(t.Title) == "" {
		return fmt.Errorf("title is empty")
	}
	if t.Status != StatusTodo && t.Status != StatusDone {
		return fmt.Errorf("unknown status")
	}
	return nil
}

func NewTask(id int, title string) (Task, error) {
	t := Task{ID: id, Title: strings.TrimSpace(title), Status: StatusTodo}
	if err := ValidateTask(t); err != nil {
		return Task{}, err
	}
	return t, nil
}

func main() {
	in := bufio.NewReader(os.Stdin)

	fmt.Print("id: ")
	idLine, _ := in.ReadString('\n')

	fmt.Print("title: ")
	titleLine, _ := in.ReadString('\n')

	id, err := strconv.Atoi(strings.TrimSpace(idLine))
	if err != nil {
		fmt.Println("invalid id:", err) // example: invalid id: strconv.Atoi: parsing "x": invalid syntax
		return
	}

	t, err := NewTask(id, titleLine)
	if err != nil {
		fmt.Println("invalid task:", err)
		return
	}

	fmt.Printf("created: %+v\n", t)
}

Here we use fmt.Errorf, because it’s convenient for building errors with text. In Go, this is the standard way to “assemble an error as a value.”

If you want to make messages even more user-friendly, you can separate “an error for the user” from “an error for the developer,” but that’s a separate, more advanced topic. For now, our goal is to not let garbage into the program.

6. Allowed values and model changes

Many people expect that if status is type Status int and we have constants, then other values “cannot happen.” They can. The compiler doesn’t forbid writing Status(123) and storing it in the field. That’s why the invariant “status is only from the set” is a real necessity.

Allowed values: why “enum via int” still must be validated

package main

import (
	"fmt"
)

type Status int

const (
	StatusTodo Status = iota + 1
	StatusDone
)

type Task struct {
	ID     int
	Title  string
	Status Status
}

func ValidateTask(t Task) error {
	if t.Status != StatusTodo && t.Status != StatusDone {
		return fmt.Errorf("unknown status: %d", t.Status)
	}
	return nil
}

func main() {
	t := Task{ID: 1, Title: "oops", Status: Status(42)}

	err := ValidateTask(t)
	fmt.Println(err) // unknown status: 42
}

This looks funny (“who would even do that?”) until you start parsing input, reading data from a file, or receiving it from the network. Then you suddenly find out that “42” is a very real threat to your peaceful sleep.

Invariants and updates: validate not only creation, but changes too

Even if you created the struct perfectly, you can still ruin it later. For example, change Title to empty, or set status to garbage. So validation is not only “at input,” but also at modification points.

We’re not using methods (that’s a separate topic), but we can write a plain function that takes a pointer to a task.

package main

import (
	"errors"
	"fmt"
	"strings"
)

type Status int

const (
	StatusTodo Status = iota + 1
	StatusDone
)

type Task struct {
	ID     int
	Title  string
	Status Status
}

func ValidateTask(t Task) error {
	if t.ID <= 0 {
		return errors.New("id must be positive")
	}
	if strings.TrimSpace(t.Title) == "" {
		return errors.New("title is empty")
	}
	if t.Status != StatusTodo && t.Status != StatusDone {
		return errors.New("unknown status")
	}
	return nil
}

func MarkDone(t *Task) error {
	if t == nil {
		return errors.New("task is nil")
	}
	t.Status = StatusDone
	return ValidateTask(*t)
}

func main() {
	task := Task{ID: 1, Title: "finish lecture", Status: StatusTodo}

	_ = MarkDone(&task)
	fmt.Println(task.Status) // 2
}

Why validate after the change? Because changes are also a “boundary,” just an internal one. And yes, sometimes it feels pedantic. But validation pedantry is cheaper than debugging pedantry of “why does it sometimes crash.”

7. How to write validation errors that actually help

A validation error is a small message that someone will read later. Often that “someone” will be you a week from now. So error messages are part of your code’s interface.

Practical rules

The message should be short and specific: "title is empty" is better than "invalid task". It’s better not to add a period at the end and not to start with "Error:", because errors are usually printed with a prefix anyway, and in logs it starts to look comical (“Error: Error: Error…”).

Go style in general is built around the idea that error is a value that is convenient to pass upward, adding context via fmt.Errorf.

If you have a chain of functions, each adding context (for example “parse id”, “create task”), then later in main you can print it with a single fmt.Println(err) and get a fairly clear picture.

8. Common mistakes

Mistake No. 1: confusing zero value with a “valid model value.”
Task{} exists, compiles, and prints fine, but most often it violates your business rules: ID = 0, Title = "", status not set. The fix is simple: keep “technically allowed” and “logically correct” separate in your head, and check the latter via ValidateTask.

Mistake No. 2: validating “somewhere later,” after data has already reached the business logic.
That’s how bugs of the form “sometimes the task list is empty” or “sometimes the task is not marked as done” appear. When validation is at the boundary (input/parsing/creation), garbage doesn’t have time to spread through the program, and debugging becomes much easier.

Mistake No. 3: returning an error that is too generic, so it’s unclear what to fix.
The message "invalid task" forces you to read the code and guess. The message "title is empty" immediately tells you what needs to be fixed. Errors are an interface, and Go encourages making them informative, because error is an ordinary value that you print, compare, and pass along.

Mistake No. 4: assuming “enum constants” automatically protect you from invalid values.
The Status type and constants improve readability, but they do not prevent writing Status(42). If status comes from input or an external source, the “only from the set” check is mandatory, otherwise the model ends up with states the code doesn’t anticipate.

Mistake No. 5: on error, returning a “partially filled” struct and then accidentally using it.
If NewTask returned an error, but you still saved somewhere Task{ID: 5, Title: "", ...}, you planted a mine for yourself. A practical pattern: on error, return Task{} and error, and the caller must check err != nil.

1
Task
Go SELF, level 21, lesson 4
Locked
Sign-in
Sign-in
1
Task
Go SELF, level 21, lesson 4
Locked
Discount coupon
Discount coupon
1
Task
Go SELF, level 21, lesson 4
Locked
Delivery status
Delivery status
1
Task
Go SELF, level 21, lesson 4
Locked
CLI Note
CLI Note
1
Survey/quiz
Struct, 21 level, 4 lesson
Unavailable
Struct
Struct: data modeling
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION