Mixins, Traits and Embedding
Four languages, one problem: share behaviour without spending the inheritance slot. Each solution picks a different thing to give up, and knowing which tells you what the code will do under change.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.
My language offers traits, mixins or embedding as a third option between inheritance and delegation. What does each one actually cost?
Twenty types across the codebase need the same audit behaviour — record who changed them and when. They are not related to each other, they already have parents, and the audit rule changes about twice a year for all of them at once.
Mix it in. One Auditable mixin, twenty classes list it, and the shared behaviour arrives without touching anyone's parent. It is one line per class and the rule lives in one place.
Mixins compose by name, and names collide. The second mixin also defines timestamp(), and which one wins is decided by the language's resolution order rather than by anyone's intent.
- Mixins compose by name, and names collide. The second mixin also defines
timestamp(), and which one wins is decided by the language's resolution order rather than by anyone's intent. - The mixin needs state — a
lastModifiedByfield — so it now assumes something about the classes it is mixed into, and the assumption is not written down or checked (Temporal Coupling). - It grows.
Auditableacquires soft-delete, then versioning, then a hook the reporting team needed, and it becomes a base class that arrives sideways and nobody owns (The Utility Dumping Ground). - Twenty classes now have twenty ways for someone to override one method quietly, and no test covers the combination that broke (Shotgun Surgery).
- Reading
order.audit()no longer tells you what runs. In Python you must know the MRO; in Scala the linearisation order; in a TypeScript mixin chain, the order of the calls that built the class.
What limits the solution, and what must never stop being true
This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.
- The types genuinely have nothing else in common, so a shared base would be a base class named after a cross-cutting concern.
- The audit format is a compliance requirement and must be identical everywhere — one change must move all twenty (Duplicate Knowledge).
- The team is polyglot: the same rule is implemented in a Go service, a Python job and a Rust library, and the three will not use the same mechanism.
- Every mutation of an auditable type produces exactly one audit record. Not zero, not two (Idempotency by Design).
- Whatever mechanism is chosen, a reader must be able to determine which implementation of
audit()actually runs, statically (Local Reasoning).
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- One place must own the audit *rule* — what a record contains and when one is written — regardless of how it is attached to types.
- Each type owns whether it is auditable and supplies whatever the rule needs, but owns none of the rule itself.
- The mechanism (trait, mixin, embedding, delegation) owns nothing. It is attachment, and choosing it is separate from choosing where the knowledge lives (Designing by Responsibility).
- The seam is the small contract the shared behaviour needs from its host:
id()andcurrentActor()and nothing else. Traits make that contract explicit and checked; mixins leave it implicit and hopeful. - A trait or interface bound is a real boundary because the compiler enforces it. A mixin is a boundary only by convention, and conventions do not survive the twelfth class (Internal Module Contracts).
- Go embedding puts the boundary at the embedded type's own interface, which is why the mechanism reads as delegation with less typing rather than as inheritance.
Five mechanisms, five things given up
Every row solves the same problem: attach behaviour to types that already have a parent, without duplicating it. They differ in when the conflict is detected, whether state is allowed, and whether a reader can tell what runs — and those three answers predict everything about how the code behaves under change.
The column that matters most in practice is the third. A mechanism that reports a conflict at compile time turns a subtle bug into a build failure, and that single property is worth more than the syntax differences people usually argue about.
| Mechanism | Shape | Conflict detected | May hold state | What it gives up |
|---|---|---|---|---|
| Rust traits | impl Auditable for Order, with bounds naming requirements | Compile time; ambiguity must be disambiguated explicitly | No — behaviour only, state stays on the type | The orphan rule: you cannot implement a foreign trait for a foreign type, so wrappers appear |
| Go embedding | type Order struct { Audited } — methods are promoted | Compile time on ambiguous selectors; shallower depth wins silently | Yes — the embedded struct has fields | Virtual dispatch: an embedded method never calls your override, which surprises everyone once |
| Python mixins | class Order(Model, Auditable) | Never — MRO resolves it and the program runs | Yes, implicitly, by assuming attributes exist | Static readability: what runs depends on the linearisation of the whole hierarchy |
| Scala traits | class Order extends Model with Auditable | Compile time for types; linearisation silently orders overrides | Yes, including constructor-ordered initialisation | Predictability of initialisation order, which is a genuine and famous source of nulls |
| Java default methods | interface Auditable { default void audit() {...} } | Compile error when two interfaces supply the same default | No — deliberately | Shared state, which is exactly the restriction that keeps it safe (When Inheritance Fits) |
The compile-time version
Rust makes the implicit contract explicit: the trait says what it needs from a host, and a type that does not supply it simply cannot implement the trait. That is the mixin problem's actual solution — not multiple inheritance made safe, but requirements made checkable.
The price is visible in the signature. Bounds accumulate, generic code sprouts where clauses, and there is a real point at which engineers reach for a free function to escape the noise. That is the trade, and it is not free either way.
1trait HasId { fn id(&self) -> Uuid; }2 3// The bound IS the contract with the host type.4trait Auditable: HasId {5 fn audit(&self, actor: &Actor) -> AuditRecord {6 AuditRecord { subject: self.id(), actor: actor.id, at: Utc::now() }7 } // one default body, twenty types8}9 10impl HasId for Order { fn id(&self) -> Uuid { self.id } }11impl Auditable for Order {} // <- the whole opt-in12 13// A type without HasId cannot implement Auditable. The14// assumption a Python mixin would leave in a docstring is15// a compile error here.The default method body is where the shared knowledge lives — one edit moves all twenty. The supertrait bound is where the implicit mixin contract became explicit.
The runtime version, and what it hides
class Order(Model, Auditable) and class Order(Auditable, Model) can behave differently with no other change; Ruby resolves by include order with the last include winning; TypeScript mixin factories resolve by application order, and the resulting type is the intersection, so a genuine conflict silently narrows to never rather than erroring where you wrote it.Python's version reads better and checks nothing. It works, it is idiomatic, and the failure it permits is specific: a second mixin defining the same name, resolved by an ordering rule that lives in the class declaration line rather than anywhere a reader will look.
This is not an argument against Python. It is an argument for a narrower habit in Python: never mix in two things that could define the same name, and never let a mixin assume an attribute it did not declare in an abstract method.
looks like A mixin whose methods reference self.id, self.tenant or self.save() without any declaration that a host must provide them — often with a docstring saying "must be used with Model".
suggests An implicit inheritance relationship. The mixin is a base class that avoided the parent slot, and the contract it depends on is enforced by nothing but the reviewer's memory.
fix Declare the requirement: an abstract method or Protocol the host must satisfy, so the failure is at class-definition time rather than at the first call in production. If two mixins can collide on a name, stop mixing and delegate to a plain object instead (Composition Over Inheritance).
How to build it
Most important first.
- Separate the knowledge from the attachment. Write the audit rule as a plain function or small type first, then decide how the twenty types reach it. Most mixin problems come from doing these in the other order.
- Prefer the mechanism your language can check. Rust traits with explicit bounds, Scala traits with declared self-types and Go interfaces all fail at compile time; Python and Ruby mixins fail in production (Making Illegal States Unrepresentable).
- Keep shared behaviour stateless where possible. A stateless trait composes freely; one that requires a field is an inheritance relationship wearing a different name.
- Make conflicts explicit rather than resolved by ordering. Rust makes you disambiguate; Scala linearises silently; if your language linearises, do not mix in two things that could ever define the same name.
- For cross-cutting concerns specifically, consider that the answer is often not language machinery at all: a decorator around the twenty call sites, or a single write path everything funnels through, moves the concern out of the types entirely (Decorator).
- Where the mechanism must be language-specific across a polyglot codebase, pin the *contract* — the record shape — in one schema and let each language attach it however it attaches things (Contract Tests).
What the next change costs
The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.
- Change the audit rule under a mixin or trait: one edit, twenty types updated, no call sites touched. This is the mechanism's real win and it is a large one.
- Change the *contract* the shared behaviour needs — audit now requires a tenant id — and traits cost twenty compile errors that name every site, while mixins cost a runtime
AttributeErrorin whichever job runs first. - Add a twenty-first type: one declaration, everywhere. This is the cheapest change in the module and it is why the mechanism exists.
- Remove the behaviour from one type: cheap under embedding and delegation, awkward under mixins, because the class must now override the method to disable it — which is refused bequest by another route (When Inheritance Fits).
- The honest comparison: plain delegation costs one field and one forwarding method per type — twenty forwarding methods — and makes every one of these changes obvious and greppable. Traits buy you those twenty methods and charge you a resolution rule.
- Traits and mixins buy sharing without spending the parent slot, and pay in resolution rules that a reader must know to predict behaviour.
- Explicit bounds are safer and noisier; the noise is real and drives people back to the unsafe option.
- Delegation is the most obvious and the most typing, and there is no version of this problem where somebody is not annoyed.
What can go wrong
- Diamond resolution surprises: two paths define the same method and the language silently picks one. Python's C3 linearisation is deterministic and still routinely surprises the person reading the class.
- A mixin is updated, and one of the twenty classes had overridden the method for a good reason two years ago. Nothing tells you; the compliance record for that type is quietly wrong.
- Rust's orphan rule blocks the obvious move — you cannot implement your trait for a foreign type from a third crate — so a newtype wrapper appears and its conversions spread (Adapter).
- The mitigation fails: making the trait's requirements explicit produces bounds so long that the signature is unreadable, and engineers start writing free functions to avoid them.
- A trait bound is an explicit, visible dependency from the shared behaviour onto its host:
impl<T: HasId> Auditable for Tsays exactly what is required. - A mixin creates an implicit dependency in both directions — the mixin assumes fields exist, the class assumes the mixin does not clash — and neither is declared.
- Go embedding creates a dependency on the embedded type's method set, which is checked, plus a promotion rule that is not obvious to readers who have not internalised it.
- "Traits are just multiple inheritance done right." They fix name resolution and state, not the underlying question of whether the behaviour is genuinely shared. A trait mixed into twenty unrelated types can still be a cross-cutting concern that should have been a boundary (Separation of Concerns).
- "Go embedding is inheritance." It is delegation with automatic forwarding: there is no virtual dispatch to the outer type, so an embedded method calling another method calls the *embedded* one, not your override. Expecting inheritance semantics here is a common and expensive mistake.
- "Use mixins to avoid deep hierarchies." Mixins convert depth into breadth. Six mixins on one class is not simpler to reason about than a three-level chain; it is harder, because the order is invisible (Local Reasoning).
- "My language has no traits, so I cannot do this." Delegation plus a small interface does the same job in any language, and costs forwarding methods. Missing language features change the price, not the design (Composition Over Inheritance).
- utility-dumping-ground
- shotgun-surgery
Testing it, and how it ages
- One shared contract test asserting the audit record shape, run against every type that claims the capability — the test suite is where the twenty-way fan-out should be visible (Contract Tests).
- In dynamically-typed languages, a test that enumerates the mixed-in classes and asserts each satisfies the implicit requirements, because nothing else will.
- A test for the collision case specifically: two mixins defining the same name, asserting which wins. If you cannot write that test confidently, the mechanism is too subtle for the codebase.
- For Go, test through the interface the embedded type satisfies, not through the outer struct, or promotion hides which implementation ran.
- Shared behaviour attracts more behaviour. Every mechanism here ages by accretion, and the audit trait at year three has soft-delete and versioning in it unless someone actively refuses.
- Traits with explicit bounds age best because each addition forces a visible, compiler-checked decision at every host type. Implicit mixins age worst for exactly the inverse reason.
- The endgame that works is usually extraction to a single write path: instead of twenty auditable types, one repository that writes an audit record for every mutation. That is a boundary rather than a language feature, and it survives a language change (Effect Boundaries).
Where this applies
This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.
- LANGUAGE-SPECIFICRust traits are compile-time checked, allow default methods, require explicit disambiguation on conflict, and are restricted by the orphan rule; Go embedding is delegation with method promotion and no virtual dispatch back to the outer type; Python and Ruby mixins are runtime name injection resolved by MRO or ancestor order; Scala traits are linearised, may hold state, and support self-types that declare their requirements; Java 8+ default methods give trait-like sharing with no state and a compile error on conflict. Everything else in this lesson depends on which of those you are holding.
- PARADIGM-SPECIFICIn a functional language the same requirement is a higher-order function or a typeclass — Haskell typeclasses are the direct ancestor of Rust traits — and the "which parent wins" question does not arise because there are no parents, only instances the compiler resolves.
- CONTESTEDThe strongest argument against all of these mechanisms is that cross-cutting behaviour attached to types is a category error: auditing is not a property of an order, it is a property of the write path, and every codebase that made it a mixin eventually funnels writes through one place anyway. The counter is that the funnel is a much larger refactor, that the mixin gets compliance shipped this quarter, and that in a codebase where every mutation genuinely does go through the type, attaching it to the type is honest modelling rather than a shortcut.
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — Programming Languages & Runtime Internals — how each mechanism dispatches: Rust monomorphises static trait calls and boxes dynamic ones, Go embedding is a compile-time forwarding rewrite, Python resolves through the MRO at every call, and those differences decide both the performance and the debuggability of the same design.