JavaRush /Courses /Go SELF /When embedding is harmful

When embedding is harmful

Go SELF
Level 23, Lesson 4
Available

1. Embedding expands the public API

At first, embedding looks like a small victory over verbosity: fewer .Inner., less “boilerplate,” less code. But that convenience has a price: the outer type suddenly starts to look like it can do much more than it actually should. The code becomes harder to read and easier to misinterpret, and anything that’s hard to read usually becomes a cozy home for bugs.

The key idea of this lecture: embedding is not only about “more convenient access.” Embedding changes the type surface area: what is available to the user via .. And a type’s surface area is your mini-contract: “what you can do with this type.”

Let’s solidify this with a small fragment of our training app “task tracker” (a task list). We’ll start with a simple model:

package main

type Task struct {
	ID    int
	Title string
	Done  bool
}

So far, everything is boring and safe. The danger starts where we decide: “let’s embed something useful.”

2. The main problems with embedding

Leaking an internal dependency

A very common scenario: you write a “service/app” struct, and inside you need a helper — an ID generator, logger, validator, counter. At some point you don’t want to write s.idGen.Next(), so you do embedding. And that’s where the magic begins, which in production is usually called: “why is this even callable?!”

Let’s make a small ID generator:

package main

type IDGen struct {
	next int
}

func (g *IDGen) Next() int {
	g.next++
	return g.next
}

Now let’s make a “project” (a task container) and embed IDGen so we don’t have to type extra:

package main

type Project struct {
	Name string
	IDGen // embedded
}

Usage looks nice:

package main

import "fmt"

func main() {
	p := Project{Name: "Home"}
	fmt.Println(p.Next()) // 1
	fmt.Println(p.Next()) // 2
}

And here is the question that should make you at least a bit uneasy: why should a user of Project have access to Next() at all? That’s internal ID-allocation mechanics, not a “project function.”

With embedding, you’re effectively saying: “A project can Next.” And a reader starts thinking that this is a normal domain operation. That’s a design problem, not a syntax problem.

Inflating the type surface area

Inflating the type surface area is when the outer type gets dozens of methods/fields that do not match its responsibility. This is especially unpleasant when you embed not a small value object but something “heavy” with a large method set.

Let’s show this on our task list type. We’ll make a wrapper type over a slice (we already know how to define methods on “slice types”):

package main

type TaskList []Task

func (tl *TaskList) Add(t Task) {
	*tl = append(*tl, t)
}

func (tl TaskList) DoneCount() int {
	count := 0
	for _, t := range tl {
		if t.Done {
			count++
		}
	}
	return count
}

Now the “project” stores tasks. And we can do embedding:

package main

type Project struct {
	Name string
	TaskList // embedded
}

It looks cute: p.Add(...), p.DoneCount(). But embedding has a side effect: not only does the behavior you wanted “leak out,” but also the fact that you store tasks exactly this way (via TaskList), plus you get direct access to the field p.TaskList.

From the outside, you can now do this:

package main

func main() {
	p := Project{Name: "Home"}

	// External code can replace the entire list directly.
	p.TaskList = TaskList{}
}

Sometimes that’s okay. But often it breaks your future plans: for example, you may want to store tasks not as a slice but as a structure with an index by ID, or enforce invariants (“IDs are unique,” “empty Title is forbidden”). But external code has already gotten used to manually poking at TaskList.

And this is a classic example of the hidden cost of embedding: you accidentally fixed the internal structure of the type as part of its public behavior.

Non-obvious origin of fields and methods

When beginners (and not only beginners) read code, a simple thing happens: they see p.Next() and try to understand where it is defined. If the method is “native” to the outer type, it’s simple: look for func (Project) Next. If it is promoted (promoted), you must remember about embedding, find the embedded type, then find the method there.

It’s not the end of the world, but the cognitive load increases. And the higher the load, the easier it is to make mistakes. This is not purely theoretical aesthetics: poorly readable code is genuinely easier to maintain incorrectly.

From a maintenance perspective, promoted methods often turn into “invisible magic”: the code compiles, but it doesn’t explain itself.

So here is a simple rule for beginners (and for tired adult developers too): if the origin of a method matters for understanding, it’s better to make access explicit.

Compare:

p.TaskList.DoneCount()

versus:

p.DoneCount()

The second is shorter, but the first immediately answers “this is about tasks.”

Embedding hides important parameters and mixes responsibilities

Sometimes embedding is used as an “everything container”: “I’ll put a struct inside and let it live there.” But if what you hide inside is something that should be controlled by the calling code, you end up with an API where behavior cannot be managed properly.

There is a well-known idea in the Go community: don’t hide something that conceptually should be a call parameter inside a struct. It makes behavior confusing and mixes different lifetimes (lifetime) and areas of responsibility.

We won’t dive into that specific mechanism (it will appear later in the course), but the design lesson is already useful now: if a dependency affects “how the method works,” then very often it’s better to pass it explicitly (as a parameter or via a controlled field), rather than “lifting” it outward via embedding so that it’s unclear where it comes from and who owns it.

In terms of a simple task tracker, it sounds like this: if the “task creation process” depends on an ID generator and validation, those are your internal details. The user should see a method AddTask(title) — not have the ability to directly call Next() on the generator.

3. Practical refactoring without embedding

Now let’s make the “correct” version of Project. We’ll remove embedding, hide dependencies, and expose only what belongs to the project’s responsibility.

Let’s start with the new structure:

package main

type Project struct {
	Name  string
	tasks TaskList // not embedded and not exported
	idGen IDGen    // internal too
}

Two things got better immediately.

First, external code no longer sees that inside Project tasks are stored specifically as TaskList. Second, external code cannot “play with” the ID generator.

Now let’s add a “proper” domain method: add a task by title.

package main

func (p *Project) AddTask(title string) Task {
	t := Task{
		ID:    p.idGen.Next(),
		Title: title,
	}
	p.tasks.Add(t)
	return t
}

Usage becomes this:

package main

import "fmt"

func main() {
	var p Project
	p.Name = "Home"

	t := p.AddTask("Buy milk")
	fmt.Println(t.ID, t.Title) // 1 Buy milk
}

Notice the effect: the user now does not know and does not need to know how you generate IDs. They only know: “add a task.” That is a good API.

4. How to expose tasks safely

When you hide internals, a question immediately appears: “Okay, so how do I show the tasks?” And here it’s very easy to step on the same rake again, just in different shoes.

If you return the internal slice as-is, the user can mutate its elements (which is ownership leaking again). We already know about slice aliasing and why “giving your buffer to the outside world” can be dangerous. So let’s make a careful method that returns a copy.

package main

func (p Project) Tasks() []Task {
	out := make([]Task, len(p.tasks))
	copy(out, p.tasks)
	return out
}

Usage:

package main

import "fmt"

func main() {
	p := Project{Name: "Home"}
	p.AddTask("Buy milk")
	p.AddTask("Clean room")

	for _, t := range p.Tasks() {
		fmt.Println(t.ID, t.Title)
		// 1 Buy milk
		// 2 Clean room
	}
}

Now external code got the data, but it did not get the right to mutate the project’s internal state directly. This is a “controlled type surface”: you expose what is needed and keep invariants to yourself.

5. Cheat sheet: when embedding is harmful

Sometimes you want a simple cheat sheet, but without turning the lecture into a three-page list. So let’s make a compact table that is useful to keep in mind when your hand reaches to embed yet another type.

Situation What usually happens Why it’s bad What to do instead
Embedding a “service” type (generator, logger, validator) Dependency methods become “as if they were domain methods” Meaning is hidden; responsibilities get mixed Named field + wrapper methods
Embedding a struct with a large API The outer type “can do everything” the dependency can do Inflated type surface area, harder to maintain Expose only the narrow methods you actually need
Embedding a type you want to change in the future Users start relying on implementation details Hard to change internals without breaking things Hide the field, expose a stable API
Embedding makes method origin non-obvious The reader doesn’t understand “where it comes from” Code is harder to read and easier to misuse Explicit access x.inner.M() or explicit methods on the outer type

This table is not a “law,” but it’s a good filter: if you recognize your case, it’s worth stopping and thinking.

6. A mini-diagram of type expansion

Sometimes it’s easier to see it once than to read about it ten times. Let’s imagine our Project in two variants as a method surface area.

flowchart TD
    A["Project (with embedding)"] --> B["Project methods"]
    A --> C["Promoted TaskList methods"]
    A --> D["Promoted IDGen methods"]

    E["Project (without embedding)"] --> F["Project methods (AddTask, Tasks, ...)"]
    E --> G["TaskList and IDGen are hidden inside"]

The variant with embedding almost always gives you more “entry points.” And more entry points means more ways to use the type incorrectly, even without malicious intent.

7. Typical mistakes

Mistake #1: embedding is done “because it’s shorter.”
When you choose embedding only to shorten x.inner.M() → x.M(), you pay with readability and API controllability. A month later (or half a year later, when the project starts living its own life) you’ll discover that a bunch of methods are sticking out, which your type should not be responsible for at all.

Mistake #2: embedding leaks a dependency that you wanted to keep internal.
A common example is an embedded ID generator or a “technical” helper. As a result, the type user gets access to methods like Next() that make no sense in the domain model. This turns internal mechanics into a public promise, and those promises are painful to change later.

Mistake #3: embedding is used in an exported type without checking “what became accessible via dot.”
If the type is public, its surface area is effectively your contract with external code. Embedding can inflate that contract by accident. And accidental contracts usually don’t survive the first serious architectural redesign.

Mistake #4: exposing internal state to the outside for the sake of convenience.
When you embed TaskList and allow direct access to p.TaskList, you make life easier for the user only until you need invariants, validation, or a storage-structure change. If you need to expose data outward, it’s better to return a copy (via make + copy) than to share the internal buffer.

Mistake #5: promoted methods reduce readability, but this is ignored.
Code where it’s unclear where a method came from is harder to read and carries a higher risk of mistakes — especially in teams and especially for beginners. In such places, it’s better either to use explicit qualification (p.tasks.DoneCount()) or to create a neat wrapper method on the outer type.

1
Task
Go SELF, level 23, lesson 4
Locked
Pass generator
Pass generator
1
Task
Go SELF, level 23, lesson 4
Locked
Notebook under the hood
Notebook under the hood
1
Task
Go SELF, level 23, lesson 4
Locked
Safe Notes
Safe Notes
1
Task
Go SELF, level 23, lesson 4
Locked
Task project
Task project
1
Survey/quiz
Embedding and Composition, 23 level, 4 lesson
Unavailable
Embedding and Composition
Embedding and Composition
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION