1. Introduction
At the start of programming, it seems that data is just values: int for numbers, string for text, bool for “yes/no”. And that’s true… right up until you catch a bug like “a task has a negative id” or “an empty title made it into the business logic because somewhere someone forgot TrimSpace”. Data modeling is a way to make some errors “unable to happen”, or at least ensure they’re caught in one place rather than scattered throughout the code.
Imagine we’re writing a small console “task list” (to-do) app. At a common-sense level, a task consists of several pieces:
- an identifier (id),
- a title (title),
- a status (todo/done).
Even if we aren’t yet “packing” these parts into a single value, we can already agree that this is one entity.
The key word here is “agree”. A program is strict: if we don’t encode the rules explicitly, it will silently accept anything, like an overly polite waiter.
2. Fields of one entity and the relationship between them
When people say “fields”, they usually mean “parts of the same entity”: a task has id, title, status. Beginners often start storing these parts “however it works”: one slice here, another slice there, another variable elsewhere. In a small example it looks fine, and then suddenly one part gets updated and the other doesn’t, and you end up with a Frankenstein task: the title from one task and the status from another.
Let’s deliberately build a slightly awkward (but very instructive) way to store tasks using “parallel slices”. This is the kind of case where the code compiles, but common sense quietly cries in the corner.
package main
import "fmt"
func main() {
ids := []int{1, 2}
titles := []string{"buy milk", "read book"}
titles = append(titles, "learn go") // forgot to add the id!
fmt.Println(len(ids), len(titles)) // 2 3
}
The problem here isn’t slices. The problem is that the relationship between the “fields” is kept in your head, not in the code. The compiler isn’t obligated to read minds (and honestly, that’s for the best).
To make the idea visual, you can imagine a diagram like this:
flowchart LR
A["id[0]=1"] --- B["title[0]='buy milk'"]
C["id[1]=2"] --- D["title[1]='read book'"]
E["id[2]=???"] --- F["title[2]='learn go'"]
The relationship “id and title with the same index are one task” breaks because of one innocent append.
What can you do right now without introducing new complex constructs? Keep the relationship via a key (id). That is, store data in a map where the key is the task identifier. Then the title field for task id=7 can’t accidentally drift to id=8, because they have different keys.
3. Data invariants
An invariant is a rule that must remain true for a “valid” value. It sounds a bit academic, but in practice it’s very down-to-earth. For example: “a task id is always greater than zero” or “a task title is not empty after trimming whitespace”. Once you formulate invariants, life gets much simpler: now you know exactly what to validate and where.
It’s convenient to write invariants down not in your head, but as a small table—almost like a spec sheet for your data:
| Entity/type | Invariant | Why it matters | Where to check |
|---|---|---|---|
|
|
0 and negative values usually mean “error/not set” | when parsing/creating |
|
not empty after normalization | empty titles break UX and filters | when creating the title |
|
only from the allowed set | otherwise output/“what to do” logic starts guessing | when setting the status |
The key idea of the day: invariants should be checked at the system boundary. The boundary is where data comes from “outside”: from stdin, arguments, a file, the network, a user—whatever, even outer space. Inside the program, it’s better to live in a world where data is already sane and predictable.
4. What types are responsible for
A very common beginner mistake is thinking types exist only “so the compiler doesn’t complain”. In reality, types are also a way to express meaning. If you have TaskID and ProjectID, and both are numbers, then at the int level it’s easy to swap them by accident. But with named types, the compiler becomes your nitpicky but helpful colleague: “no, that’s not the right meaning”.
Let’s start simple: define TaskID on top of int. This doesn’t make the program “more OOP” (don’t worry); it makes it less accidental.
package main
import "fmt"
type TaskID int
func main() {
var id TaskID = 10
fmt.Println(id) // 10
}
By itself, the type still doesn’t guarantee the invariant id > 0. But it creates a “container of meaning”: now we have a place where that meaning can be enforced.
And this is where the idea of “type responsibility” appears. If TaskID is a task identifier, then it’s logical that the rule “it must be > 0” is validated near that type, rather than smeared across 17 places in the code where we “just in case” write if id <= 0.
5. Factory functions and normalization
A factory function is a function that takes “raw” input, normalizes/validates it, and returns either a correct value or an error. This is one of the most practical patterns in Go: it works well with the (T, error) contract, with the habit of early returns, and with the fact that errors in Go are just ordinary values.
Let’s start by parsing a task identifier from a string. Inside, we do two things: convert the string to a number and check the invariant. If anything goes wrong, we return an error.
package main
import (
"errors"
"strconv"
"strings"
)
type TaskID int
func ParseTaskID(s string) (TaskID, error) {
n, err := strconv.Atoi(strings.TrimSpace(s))
if err != nil || n <= 0 {
return 0, errors.New("task id must be a positive integer")
}
return TaskID(n), nil
}
Notice an important detail: we return 0 as the value on error. This is convenient because 0 becomes an “explicitly invalid” option for TaskID. That is, the type’s zero value starts to mean something: “id not created/invalid”.
If you want to add context to errors while preserving the original cause so it can be checked through a chain, Go typically does this via error wrapping. This approach is described in detail in materials about error handling and cause chains.
Now let’s do the same for a task title. Here we use whitespace normalization: trim whitespace at the edges and “collapse” multiple spaces inside.
package main
import (
"errors"
"strings"
)
type TaskTitle string
func NewTaskTitle(s string) (TaskTitle, error) {
parts := strings.Fields(strings.TrimSpace(s))
if len(parts) == 0 {
return "", errors.New("title is empty")
}
return TaskTitle(strings.Join(parts, " ")), nil
}
Notice how convenient this is for the rest of the code: from here on, you’re not working with “some string”, but with a TaskTitle that’s already normalized. You “cleaned the room” once, and then you don’t trip over socks in every corner.
Input normalization
Normalization is a separate step that people often forget because “well, the user is reasonable”. Spoiler: the user is reasonable, but their spaces might be tabs, their letters might have inconsistent casing, and in general they type like a human, not like a compiler. If you normalize strings on input, the logic becomes simpler: comparisons work reliably, search works reliably, and tests become less flaky.
Let’s show a small example using a command in our CLI app. Suppose the user enters a string like add BUY milk. We want to get the command and the title.
package main
import (
"fmt"
"strings"
)
func main() {
raw := " add BUY milk "
parts := strings.Fields(raw)
fmt.Printf("%q\n", parts) // ["add" "BUY" "milk"]
}
Now we can lowercase the command and assemble the title back “nicely”:
package main
import (
"fmt"
"strings"
)
func main() {
parts := strings.Fields(" add BUY milk ")
cmd := strings.ToLower(parts[0])
title := strings.Join(parts[1:], " ")
fmt.Println(cmd, title) // add BUY milk
}
Yes, this is still “manual” work. But the point is that normalization isn’t decoration; it’s a way to make the data model resilient. A good model should not depend on whether the user typed two spaces or five.
6. Mini task model: storage and validation boundaries
Now we’ll assemble a small task storage model so that it’s predictable and doesn’t require “magic”. We won’t introduce heavy new constructs yet; instead, we’ll use familiar maps and slices, but we’ll add meaning through named types and invariants. It will look a bit more verbose, but much more honest: you can see what is stored where and who is responsible for what.
Let’s define three domain types: TaskID, TaskTitle, Status. For Status, we’ll define allowed values using constants. Note: we’ll keep StatusUnknown as zero—this is convenient to mean “not set”.
package main
import "fmt"
type Status int
const (
StatusUnknown Status = iota
StatusTodo
StatusDone
)
func main() {
fmt.Println(StatusTodo, StatusDone) // 1 2
}
Now let’s decide how to store the task “fields”. We’ll do it like this:
- titles — a map from id to title,
- statuses — a map from id to status,
- order — a slice of ids in insertion order (so output is stable and doesn’t depend on range over a map).
This isn’t the only option, but it illustrates the idea “fields are tied together through a key”.
package main
type TaskID int
type TaskTitle string
type TaskTitles map[TaskID]TaskTitle
func main() {
// storage and logic will appear here later
}
Let’s also add a type for statuses:
package main
type TaskID int
type Status int
type TaskStatuses map[TaskID]Status
func main() {
// storage and logic will appear here later
}
Now let’s write a small function to add a task. It will take the “field storage” and return the updated order. Note: we’re not trying to “mutate a slice internally” invisibly—we return the result, as is customary with slices after append.
package main
type TaskID int
type TaskTitle string
type Status int
const (
StatusUnknown Status = iota
StatusTodo
StatusDone
)
func AddTask(
order []TaskID,
titles map[TaskID]TaskTitle,
st map[TaskID]Status,
id TaskID,
t TaskTitle,
) []TaskID {
titles[id] = t
st[id] = StatusTodo
return append(order, id)
}
The important part is this: id is the key, and by that key we update both maps. So the relationship between fields doesn’t drift like it does with parallel slices.
To keep output stable, we iterate over order, not over range titles. This is the same “deterministic output” principle you’ve already seen with maps: range order is not a contract, and relying on it is like relying on a cat’s mood.
Where to catch errors: system boundaries
The most important thing now is not to build “perfect architecture”, but to learn to place validation in the right place. For our mini app, the right place is at command input: where we read a line, split it into parts, and convert it into domain values (TaskID, TaskTitle). Internal logic should receive already-valid values; otherwise it will be forced to defend itself with endless ifs and turn into an obstacle course.
Let’s show a small diagram of the data flow:
flowchart TD
A["stdin: raw string"] --> B["normalization: TrimSpace/Fields"]
B --> C["parsing: ParseTaskID / NewTaskTitle"]
C -->|ok| D["logic: AddTask/DoneTask/ListTasks"]
C -->|error| E["print error to the user and exit/skip"]
If you keep this boundary disciplined, you can think more simply inside the application: “if I have a TaskID, it’s valid, otherwise I wouldn’t have gotten here”. That’s the practical point of modeling.
And here it’s useful again to remember error wrapping: when you return an error upward, you can add context (for example, “parse id”, “read command”) while preserving the cause. This approach is built into standard Go error-handling practices.
7. Common mistakes in data modeling
When you start modeling data, bugs become “more honest”: they’re either caught at input, or there are fewer of them. But other temptations appear: to complicate the model for beauty, to smear rules across the code, or, conversely, to ignore invariants and hope you “get away with it”. This section is about the rakes people step on most often—and why they still sound like “thud” even in a quiet office.
Mistake #1: invariants exist only in comments or in your head.
Sometimes people write: “id must be > 0”, but they add the check once in one place and forget it in another. A week later a new input path appears, and 0 starts slipping in there. The fix is simple: validate the invariant centrally via a factory function or parser.
Mistake #2: the same entity is represented as bare int/string throughout the code.
When id is just an int, it’s easy to confuse it with anything: an index, a count, a birth year (don’t ask). A named type like type TaskID int doesn’t double the program length, but it makes it far less accidental: the compiler starts helping.
Mistake #3: normalization is done “somewhere later”, and later never comes.
If you compare strings before TrimSpace/Fields, you get “ghost bugs”: everything looks the same, but one input with two spaces suddenly can’t find a task. The practice is simpler: normalize at input, then work only with normalized values.
Mistake #4: the zero value has no meaning, and checks spread everywhere because of it.
If TaskID(0) is sometimes “valid” and sometimes “error”, the code grows endless if id == 0 checks. It’s better to agree: either the zero value is allowed and meaningful, or it means “not created”, and then you create values only through a factory function that won’t allow zero.
Mistake #5: trying to “model everything” right now and drowning in types.
Modeling is not a contest of “who can write more types”. Do it where meaning really matters: identifiers, statuses, key strings (name, email, etc.). If you start creating a type like type UsernameChar rune, that’s no longer modeling—it’s a quiet hobby, and your colleagues might start to worry.
GO TO FULL VERSION