This has bugged me for a while, so I asked GPT.
Question
What have you heard of design principles in the object orientation space. I generally subscribe to encapuslation and keeping data with the behaior, but with years of experience I see cracks in the wall, specifically regarding the tension between "what should know HOW to do something" (answer clearly being the appropriate object) VS "what should know when and what messages to dispatch to what objects" (answer being less clear if we have public methods on objects). I think record creation stands out as a prominent special case where encapuslation does not quite work. For example, I have a nesting of models Parent -> (many)Child. And I need a versioning/copying functionality for the aggregate. I do not think Child should know anything about duplicating itself, the public API should be Parent.find(123).duplicate! and the method should orchestrate duplication of the nested records.
Answer
You're describing a very mature and nuanced tension in object-oriented design—something many experienced developers confront as they move from theoretical purity (e.g. “Tell, don’t ask”; encapsulation; single responsibility) into the messy domain of real-world complexity.
You're right to highlight encapsulation and “behavior-close-to-data” as core OO ideas—but also right to sense their limits, especially in orchestration-heavy scenarios like object duplication, versioning, or domain-level transactions that span multiple models.
So where do we put orchestration logic that must traverse multiple objects? Some options:
✅ Options for Handling the Tension
1. Orchestrator or Service Object Pattern (Explicit Application Layer)
You create a dedicated class:
class ParentDuplicator def initialize(parent) @parent = parent end def call new_parent = @parent.dup new_parent.children = @parent.children.map { |child| child.dup } new_parent.save! new_parent end end ParentDuplicator.new(Parent.find(123)).call
🟢 Pros:
- Keeps orchestration logic out of domain models.
- Testable, composable.
- Models remain lean.
🔴 Cons:
- Moves behavior away from data.
- Slightly procedural.
- Some see this as breaking OO purity.
This is very common in DDD (Domain-Driven Design) and “hexagonal”/ports-and-adapters architectures, where orchestration is elevated out of entities and into use cases (often called "application services").
2. Make the Aggregate Root Responsible (Your Example)
Let Parent do the orchestration:
class Parent def duplicate! new_parent = dup new_parent.children = children.map(&:dup) new_parent.save! new_parent end end
🟢 Pros:
- Keeps domain logic within the model.
- Makes the call site simple and intuitive:
Parent.find(123).duplicate!
🔴 Cons:
- Parent accumulates coordination logic; can bloat over time.
- Harder to reuse logic across different duplication scenarios.
This works best if you're comfortable with your aggregate root knowing about its internals (a DDD-approved idea), and you don’t expect the duplication logic to vary or get more complex.
3. Delegate Duplication to the Nested Object
You'd have:
class Child def duplicate dup end end
And Parent#duplicate!
calls child.duplicate
.
🟢 Pros:
- Encourages polymorphism, reuse.
- Might be useful if
Child
has internal relationships or constraints.
🔴 Cons:
- Child is now coupled to a context-specific behavior (versioning/duplication).
- Violates SRP if duplication is not core to Child.
This works better when duplication is intrinsic to the object’s identity (e.g. a Task that can be forked) rather than contextual orchestration (like versioning a tree of data).
🧠 Philosophical Take
Encapsulation is valuable, but contextual behaviors like versioning, importing, duplicating, transforming, etc., don’t always belong in the domain objects. These behaviors are:
- Temporal (used in specific moments)
- Often cross-object
- Might require knowledge the object shouldn't have (e.g. associations, persistence, time, request context)
This is where "Application Layer" logic comes into play. And that’s not a betrayal of OO—it’s an acknowledgment that domain entities model state and invariants, not every procedural flow.
My agreement
Spot on! I think "Might require knowledge the object shouldn't have" is the key insight. We're not breaking from OOP, but reframing what is the knowledge context and what is the object that is most appropriate to encapsulate the context.
Top comments (0)