Decoupling Archetypes: Identity Over Inheritance
Inheritance looks elegant until it couples your entire architecture. Learn why Business Archetypes should be connected by identity, not shared base classes, and how this keeps Bounded Contexts independent and maintainable.
If you would like to see archetypes applied in practice, I maintain an open-source repository demonstrating archetypes implemented alongside Domain-Driven Design patterns: https://github.com/smalaca/archetypes
In the previous article, we explored the structural anatomy of the Party archetype. We saw how it separates core identity from specialized types like Person and Organization, and how Roles and Relationships define behavior. Now, we’ll look at the technical implementation strategy that keeps these models clean and maintainable.
When engineers first discover Business Archetypes, their instinct is often to reach for the most powerful tool in the Object-Oriented toolbox: Inheritance.
It seems logical. If a Person is a Party and an Organization is also a Party, why not create a Party base class and have everything else inherit from it? It feels clean, DRY, and "correct" from a modeling perspective.
But in a modern, modular architecture—especially one guided by Domain-Driven Design—this is a trap. Direct inheritance across Bounded Contexts is a shortcut to a distributed monolith.
The Inheritance Trap: The Wrong Way #1
The problem with inheritance is that it creates the strongest possible coupling between concepts. Consider this "clean" shared library approach using the Party Archetype:
// Shared Library or Core Module - WRONG
package com.smalaca.shared.core;
@ArchetypeParty.Party
public abstract class Party {
private final PartyId id;
private final List<Address> addresses;
private final TaxNumber taxNumber; // Needed by Accounting
private final List<Role> roles; // Needed by IAM
// Logic that operates over fields not used by everyone
public void validateTaxInformation() { ... }
}
// Users Context
package com.smalaca.users;
@ArchetypeParty.Person
public class User extends Party { ... }
// Accounting Context
package com.smalaca.accounting;
@ArchetypeParty.PartyRole
public class Buyer extends Party { ... }This approach leads to several critical issues:
- Polluted Abstractions: You end up putting fields (like
taxNumber) in thePartybase class that are only used by one or two contexts, forcing every other context to carry that "dead weight." - Context-Specific Logic Leakage: Methods like
validateTaxInformation()start appearing in the base class because they operate onPartyfields, even though they only make sense in theAccountingcontext. This makes the "base" model increasingly fragile. - Synchronization Nightmares: In a distributed system, if
Partyis a shared entity, synchronizing changes across microservices becomes a bottleneck. Every time a field in the base class changes, you risk breaking binary compatibility or requiring a coordinated deployment of all services.
The "God-Object" Trap: The Wrong Way #2
Another common mistake is trying to keep everything in a single, "omnipotent" entity while attempting to apply the Party Archetype. We see this most often with a bloated User class that tries to play all roles at once.
// One Entity for everything - WRONG
@ArchetypeParty.Person
public class User {
@ArchetypeParty.PartyIdentifier
private final UserId id;
private final String login;
@ArchetypeParty.PartyRole
private final List<Role> roles;
// Fields for the 'Student' role
private final List<CourseId> enrolledCourses;
// Fields for the 'Trainer' role
private final String bio;
// Fields for the 'Buyer' role (Accounting)
private final TaxNumber taxNumber;
// ... 50 more nullable fields
}This is dangerous for several reasons:
- Data Leakage: You have more details than you should in any given context. A developer working on
Course Enrollmentmight accidentally usetaxNumberjust because it's available on theUserobject, creating a dependency that shouldn't exist. - Cognitive Load: It becomes incredibly hard to determine which data is actually useful or "active" in a specific business flow.
- Constraint Violation: Because the logic for different roles is mashed together, you may violate business constraints by modifying a field in the wrong place. For example, updating a
biomight accidentally trigger logic that was intended fortaxNumbervalidation, leading to corrupted state or unexpected side effects.
The Solution: Reference by Identity
Instead of forcing a shared class hierarchy or a single bloated entity, we should treat each Bounded Context as an independent world. We still use the Party Archetype, but we implement it differently: each context has its own Party representation, and they are linked only by a simple Identity.
In the Training Center project, we don't share a Party class. Instead, we use specific IDs and context-specific roles to bridge the gap while maintaining the Party Archetype structure.
Consider how the Party is represented in different contexts:
package com.smalaca.trainingcenter.users;
// Users Management Context
@ArchetypeParty.Person
public class User {
@ArchetypeParty.PartyIdentifier
private final UserId userId;
private final String login;
private final PersonalData personalData;
}
package com.smalaca.trainingcenter.accounting;
// Accounting Context
@ArchetypeParty.PartyRole
public class Buyer {
@ArchetypeParty.PartyRoleIdentifier
private final BuyerId buyerId; // Same value as UserId
private final TaxNumber taxNumber;
private final List<Invoice> invoices;
}Notice the separate classes. This solves the problems mentioned in the traps:
- Solves the Inheritance Trap: The
Buyerdoes not inherit fromUseror a sharedPartybase class. IfUserneeds to add aloginHistory, theBuyerclass in the Accounting context is completely unaffected. There is no shared base class to synchronize. - Solves the God-Object Trap: The
Buyerclass contains only the data it needs for accounting. It doesn't have aloginorpasswordHash. TheUserclass doesn't have ataxNumber. The boundaries are enforced by the type system and the physical separation of classes. - Semantic Clarity: A
BuyerIdin the Accounting context tells you exactly what this ID represents there, even if it refers to the same human being asUserId.
The Trade-offs
Choosing identity over inheritance is not a "free lunch." You must be aware of the costs:
- Multiple Perspectives: The same physical entity (e.g., a person) will be represented through various perspectives across the system. This is intentional, but it requires discipline.
- Distributed Consistency: You must carefully control where the "source of truth" for specific fields lies. For example, if a name changes, you need a strategy (like Domain Events) to propagate that change if other contexts need the updated value.
- Data Duplication: In distributed systems, you might end up duplicating some data (like a name or email) to keep services autonomous. The cost of this duplication and the complexity of eventual consistency is the price you pay for architectural flexibility and independent scalability.
The Rule of Engagement
When implementing archetypes across your system, follow this rule: Align on structure, decouple on implementation.
- Shared Identity: Use UUIDs or strongly-typed IDs to refer to the same logical entity across Bounded Contexts.
- Context-Specific Data: Only include the data that is relevant to the current context. Don't pull in "nice-to-have" fields from other domains.
- Archetype Blueprints: Use archetypes to ensure that even though the implementations are separate, they follow the same business logic "shapes" (e.g., how a Party interacts with Roles).
Summary
Decoupling is not about avoiding commonality; it's about avoiding the wrong kind of commonality. Business Archetypes give you a shared language and structure, while Identity-based references give you the freedom to evolve.
Don't let the allure of inheritance or the "one size fits all" entity lead you into a maintenance nightmare. Keep your archetypes clean, keep your contexts bounded, and use IDs to tell the story of how they connect.
Comments ()