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.
GO TO FULL VERSION