JavaRush /Courses /Go SELF /Composition as a style for extending behavior in Go

Composition as a style for extending behavior in Go

Go SELF
Level 23, Lesson 1
Available

1. Why Go talks about “composition” rather than “inheritance”

If you come to Go from languages with classes and inheritance, the first reaction is usually: “how do I make AdminUser extends User?”. In Go the common answer is: “you don’t, and you don’t need to.” And this is not snobbery, but a practical language choice: it’s easier to extend behavior via “has-a” than via “is-a”.

With inheritance, there is a temptation to build a hierarchy that looks nice on a diagram, but in reality turns into a “family tree of noble titles”: it’s hard to change the base class, hard to understand where a method came from, and very easy to accidentally inherit something that is irrelevant.

In Go, the main question sounds different: which small parts to assemble a type from, so it does its job, and so the code makes each part’s responsibility visible.

A small diagram to lock the idea in:

flowchart TD
    A[Type A] -->|contains field| B[Type B]
    A -->|delegates via method| B
    A -->|can hide details| C[its own wrapper methods]

So it’s not “A inherits from B”, but “A contains B and delegates work to B when needed”.

2. Data composition: a structure made of parts

When we say “composition”, we often start with the simplest thing: a struct includes other structs as fields. This is not about OOP magic concepts—it’s literally “there is another struct inside”.

Let’s imagine we continue our training app—a small task manager (todo). Before this, we could have a Task struct with fields Title and Done. Now we want to add “service” data: for example, the author and the “project” the task belongs to.

Let’s make small “building blocks”:

package main

type Meta struct {
	Author  string
	Project string
}

type Task struct {
	Title string
	Done  bool
	Meta  Meta // composition: Task contains Meta
}

Here’s the key idea: Meta is a separate semantic piece. You can reuse it in other entities without copying fields.

Initialization remains straightforward and clear:

package main

import "fmt"

type Meta struct{ Author, Project string }
type Task struct {
	Title string
	Done  bool
	Meta  Meta
}

func main() {
	t := Task{Title: "Read Go spec", Meta: Meta{Author: "Ann", Project: "study"}}
	fmt.Println(t.Meta.Author) // Ann
}

Data composition is the base. But in Go the value usually appears on the next step: when behavior also starts being assembled from parts.

3. Behavior composition: small types and delegation

In Go, methods belong to types. So if we want to extend behavior, it’s beneficial to make small types responsible for small pieces of logic, and then “plug them in” via fields.

For example, suppose we need to manage the task status cleanly. Yes, we could keep Done as a bool. But as soon as additional statuses appear (for example, “in progress”, “postponed”), bool becomes too tight, like shoes two sizes too small.

Let’s make a separate Status type (we already know how to make custom types based on numbers/strings) and add methods:

package main

type Status string

const (
	StatusTodo Status = "todo"
	StatusDone Status = "done"
)

func (s Status) IsDone() bool { return s == StatusDone }

Now Task can contain the status:

package main

type Task struct {
	Title  string
	Status Status
}

func (t Task) Done() bool { return t.Status.IsDone() }

Notice the technique: Task.Done() is a wrapper method. It does not have to expose how status works internally. It delegates the decision to Status, while the task API stays convenient: “is the task done or not”.

Usage example:

package main

import "fmt"

type Status string

const (
	StatusTodo Status = "todo"
	StatusDone Status = "done"
)

func (s Status) IsDone() bool { return s == StatusDone }

type Task struct {
	Title  string
	Status Status
}

func (t Task) Done() bool { return t.Status.IsDone() }

func main() {
	t := Task{Title: "Write code", Status: StatusDone}
	fmt.Println(t.Done()) // true
}

Behavior composition often looks exactly like “a field + a small wrapper method”. And yes—it may feel a bit more “verbose” than inheritance. But you keep control: you decide what to expose, and what to keep as an internal detail.

4. Embedding and API control

Embedding as an “access accelerator”

In the previous lecture (111) we already saw embedding: you can write an unnamed field in a struct, and then the fields/methods of the embedded type are “promoted” and become accessible as if directly.

For example:

package main

type Meta struct {
	Author  string
	Project string
}

type Task struct {
	Title string
	Meta // embedded field
}

Now t.Author is the short form of t.Meta.Author.

But embedding is just one of the composition tools. The important design meaning to state here is:

If you use embedding, you are telling the code reader: “the fields/methods of Meta are part of the surface (API) of Task.”

Sometimes that’s what you need. Sometimes it isn’t.

To better feel the difference, let’s compare options:

Technique What access looks like What you promise to the type’s user
Named field (Meta Meta) t.Meta.Author “Meta is the task’s internals; I show it explicitly”
Embedding (Meta) t.Author “Author and Project look like Task’s own fields”
Field + wrapper methods t.Author() or t.Project() “I expose only what I consider part of the API”

The more you accidentally “push outward”, the harder it becomes to change internals later without breaking callers.

Wrapper methods: controlling the type surface

A very common Go-code “growing up” path looks like this: first you do embedding because it’s “shorter”, then time passes and you realize methods/fields “leaked” outward that should not have been a public part of the type at all.

Then you switch to a field + wrapper methods.

Let’s get back to our todo app. Suppose we want to store author and project, but allow changing them only through separate functions, for example with validation.

Let’s make an “internal” struct and an open API via methods:

package main

type Meta struct {
	author  string
	project string
}

func (m Meta) Author() string  { return m.author }
func (m Meta) Project() string { return m.project }

And in Task we store meta Meta and provide safe access:

package main

type Task struct {
	Title string
	meta  Meta
}

func (t Task) Author() string  { return t.meta.Author() }
func (t Task) Project() string { return t.meta.Project() }

Yes, there is a bit more code. But now you have a single point of control: you can change how Meta works internally without breaking the outside world. This is composition as a style: inside you can store anything; outside you provide a clean contract.

An analogy with errors fits here: when you “wrap” an error and allow it to be unwrapped further, you effectively make the internal cause part of the observable API. With embedding it’s similar: promoted methods and fields can also become something users of your type start relying on.

Embedding or a field: a practical choice

It’s very tempting to get a universal rule, but in programming such requests usually get you… more bugs.

Still, there is a practical way to think about it. If you embed a type, you automatically make its fields/methods part of your type’s “face”. This can be good if the embedded type truly is a logical piece of the object itself.

If the embedded type is a dependency (for example, a logger, a client, a config), embedding often inflates the API and makes the code less obvious: suddenly your service has a dozen methods that do not directly belong to the service.

Let’s compare with a short example:

package main

import "fmt"

type Logger struct{}

func (Logger) Debug(msg string) { fmt.Println("DEBUG:", msg) }

type ServiceA struct{ Logger }        // embedding: Debug will be promoted outward
type ServiceB struct{ logger Logger } // field: we control access

func main() {
	ServiceA{}.Debug("hi") // DEBUG: hi
}

In ServiceA, the Debug method became part of the service surface, even though the service may be about tasks, not logging. In ServiceB you can provide a clean method like LogDebug, or even log only internally without exposing the logger at all.

5. Mini-refactor: Task and StoredTask

Now let’s make an example that often appears in real applications: there is a domain entity “task”, and there is a “task in storage”, which gains an ID.

In classic OOP you might want to say: “StoredTask inherits from Task”. In Go we do it differently: “StoredTask contains Task”.

Option 1: named field

package main

type Task struct {
	Title  string
	Status Status
}

type StoredTask struct {
	ID   int
	Task Task // explicit composition
}

Usage will be clear:

package main

import "fmt"

type Status string

const (
	StatusTodo Status = "todo"
)

type Task struct {
	Title  string
	Status Status
}

type StoredTask struct {
	ID   int
	Task Task
}

func main() {
	st := StoredTask{ID: 10, Task: Task{Title: "Buy milk", Status: StatusTodo}}
	fmt.Println(st.Task.Title) // Buy milk
}

Option 2: embedding

package main

type StoredTask struct {
	ID int
	Task // embedded
}

Then st.Title will work directly. This is convenient when you really want StoredTask to look “like Task + ID”. But this is a design decision, not a technical necessity.

6. Service composition: an object built from multiple components

Composition in Go is useful not only for data models, but also for “service” objects that perform operations. Even if we are not building a big architecture with packages and interfaces yet, we can already write code in a readable way: one type—one responsibility, and complex behavior is assembled from several small parts.

For example, let’s make a small validator for a task title. It does not have to know about the whole Task; it is responsible only for validating a string:

package main

import "fmt"

type TitleValidator struct{}

func (TitleValidator) Validate(title string) error {
	if len(title) == 0 {
		return fmt.Errorf("title is empty")
	}
	return nil
}

Now let’s make a “task service” that contains the validator as a field and uses it:

package main

type TaskService struct {
	validator TitleValidator
}

func (s TaskService) NewTask(title string) (Task, error) {
	if err := s.validator.Validate(title); err != nil {
		return Task{}, err
	}
	return Task{Title: title, Status: StatusTodo}, nil
}

And an example of “wiring” in main:

package main

import "fmt"

func main() {
	svc := TaskService{validator: TitleValidator{}}
	t, err := svc.NewTask("Learn Go")
	fmt.Println(t.Title, err) // Learn Go <nil>
}

This is composition in its pure form: TaskService does not “inherit the validator”, it uses it. And such code is usually easier to test/change, because details are not hidden in inheritance magic.

7. Common mistakes when using composition

Mistake #1: trying to “port an OOP hierarchy as-is”, simply replacing extends with embedding.
Embedding can indeed look like inheritance because of short access like x.Method(), but semantically it’s different. In Go you don’t have to build a type tree. It’s usually easier to make a couple of small structs and compose them with fields than to try to replicate “base class → 7 subclasses → 12 overrides”.

Mistake #2: accidentally inflating the API by embedding large types.
With embedding it’s easy to “drag outward” too much. It’s especially painful when the type is exported: external users start calling promoted methods, and later you can’t change internals safely. If you feel only 2–3 operations should be visible, prefer a named field and wrapper methods.

Mistake #3: hiding the model’s meaning for the sake of shorter code.
t.Author is shorter than t.Meta.Author. But if a month later you look at Author and don’t understand where it came from and why it belongs to a task, the short access stops being helpful. Where clarity matters, it’s fine to write it out fully: t.Meta.Author.

Mistake #4: mixing data and behavior without clear responsibility boundaries.
Sometimes beginners put fields, a dozen methods, and “a bit more logic for the future” into one struct. Composition as a style helps with the opposite: extract small types (validator, metadata, status), and then assemble an object from parts. If a type starts “inflating”, it’s often a signal: time to pull a piece out into a separate component and connect it via a field.

Mistake #5: overusing embedding of pointers without clear invariants.
Embedding *T is convenient, but the zero value of such a field is nil, and access to promoted fields/methods can lead to a panic. If you embed *T, keep a clear initialization contract: either always create the value via a constructor, or store a plain value T, or do explicit checks.

1
Task
Go SELF, level 23, lesson 1
Locked
Author Card
Author Card
1
Task
Go SELF, level 23, lesson 1
Locked
Record in the database
Record in the database
1
Task
Go SELF, level 23, lesson 1
Locked
Status by the Rules
Status by the Rules
1
Task
Go SELF, level 23, lesson 1
Locked
Task creation service
Task creation service
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION