CompositionLANGUAGE-SPECIFICPARADIGM-SPECIFICCONTESTED

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.

The question

My language offers traits, mixins or embedding as a third option between inheritance and delegation. What does each one actually cost?

The requirement

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.

The obvious build

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.

Why it breaks

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.

How it breaks as requirements change
  • 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 lastModifiedBy field — 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. Auditable acquires 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.
RequirementConstraintsInvariantsResponsibilitiesBoundariesInterfacesStateDependenciesFailureImplementationTestsFeedbackEvolution

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.

Constraints
  • 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.
Invariants
  • 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.

Responsibilities
  • 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).
Boundaries
  • The seam is the small contract the shared behaviour needs from its host: id() and currentActor() 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.

MechanismShapeConflict detectedMay hold stateWhat it gives up
Rust traitsimpl Auditable for Order, with bounds naming requirementsCompile time; ambiguity must be disambiguated explicitlyNo — behaviour only, state stays on the typeThe orphan rule: you cannot implement a foreign trait for a foreign type, so wrappers appear
Go embeddingtype Order struct { Audited } — methods are promotedCompile time on ambiguous selectors; shallower depth wins silentlyYes — the embedded struct has fieldsVirtual dispatch: an embedded method never calls your override, which surprises everyone once
Python mixinsclass Order(Model, Auditable)Never — MRO resolves it and the program runsYes, implicitly, by assuming attributes existStatic readability: what runs depends on the linearisation of the whole hierarchy
Scala traitsclass Order extends Model with AuditableCompile time for types; linearisation silently orders overridesYes, including constructor-ordered initialisationPredictability of initialisation order, which is a genuine and famous source of nulls
Java default methodsinterface Auditable { default void audit() {...} }Compile error when two interfaces supply the same defaultNo — deliberatelyShared 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.

The requirement is in the type, not in a comment
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 types
8}
9
10impl HasId for Order { fn id(&self) -> Uuid { self.id } }
11impl Auditable for Order {} // <- the whole opt-in
12
13// A type without HasId cannot implement Auditable. The
14// assumption a Python mixin would leave in a docstring is
15// 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

LANGUAGE-SPECIFICPython resolves the collision by C3 linearisation of the class declaration order, so 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.

smellMixin that assumes its host

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).

when this is fine Genuinely fine in a codebase where the mixin and every host live in one module, the set is small and closed, and a test enumerates the hosts — the same conditions that make a closed hierarchy safe. It is also fine for framework mixins whose host contract is the framework's own documented base, since that contract is stable by external commitment rather than by hope (When Inheritance Fits).

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.

Cost of the next change
  • 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 AttributeError in 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.
What the recommended approach costs
  • 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

Failure modes
  • 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.
Dependencies, and their direction
  • A trait bound is an explicit, visible dependency from the shared behaviour onto its host: impl<T: HasId> Auditable for T says 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.
Misreads
  • "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).
Smells this explains
  • utility-dumping-ground
  • shotgun-surgery

Testing it, and how it ages

What to test, and at which boundary
  • 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.
How this design ages
  • 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.

Concurrencyimmutability
Domains that do not exist yet
  • 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.