ADR-0008: Accessing State of Encapsulated Aggregate¶
Status¶
Accepted
Context¶
Although in Python encapsulation is conventional and based on naming conventions, the project has a requirement “ADR-0003: Go Portability Considerations” that code should be easily portable to other programming languages, specifically to Golang. Therefore, we will assume that external access to protected aggregate attributes is absent, and examples will be considered in Golang.
Encapsulation plays a critically important role in managing complexity. Its purpose is to guarantee invariant enforcement.
As Michael Feathers said:
💬️ “OO makes code understandable by encapsulating moving parts. FP makes code understandable by minimizing moving parts.” – Michael Feathers
The question arises of how to preserve Aggregate encapsulation when we need its internal state to construct an SQL query, or, conversely, need to set the Aggregate state from an SQL query result.
There are several approaches. Let’s examine them in detail.
Memento pattern¶
Memento turned out to be close, but not quite the right fit. The essence of Memento is that it must not reveal its state to anyone other than its originator:
Preserving encapsulation boundaries. Memento avoids exposing information that only an originator should manage but that must be stored nevertheless outside the originator. The pattern shields other objects from potentially complex Originator internals, thereby preserving encapsulation boundaries.
—“Design Patterns: Elements of Reusable Object-Oriented Software” by Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides
Nevertheless, this approach is used by some authoritative sources, for example, here and here.
💬 The event is stored using some form of serialization, for the rest of this discussion the mechanism will assumed to be built in serialization although the use of the memento pattern can be highly advantageous.
<…>
Many use the default serialization package available with their platform with good results though the Memento pattern is quite useful when dealing with snapshots. The Memento pattern (or custom serialization) better insulates the domain over time as the structure of the domain objects change. The default serializer has versioning problems when the new structure is released (the existing snapshots must either deleted and recreated or updated to match the new schema). The use of the Memento pattern allows the separated versioning of the snapshot schema from the domain object itself.
Valuer & Scanner¶
The Scanner interface opens the door to Value Object mutability, which contradicts its fundamental nature. It also creates a breach in Aggregate encapsulation. To be fair, it can be implemented in such a way that it is only mutable once, by first verifying that its value has not been set. This approach is often used for Identity Value Object for auto-increment primary keys.
However, there is another issue - the Scan(src any) error method is called on a concrete type,
which prevents the use of the pattern known as
Special Case
or
Null Object.
Additionally, in some cases it may be necessary to transform immutable historical data for a new model version. This issue was addressed in section “4. Validating historical data” of the article “Always-Valid Domain Model” by Vladimir Khorikov and in section “6. The use of ORMs within and outside of the always-valid boundary” of the article “Database and Always-Valid Domain Model” by Vladimir Khorikov.
On the other hand, Valuer can only return primitive types, which means it is not suitable for exporting the hierarchical structure of Aggregate state:
It is either nil, a type handled by a database driver’s NamedValueChecker interface, or an instance of one of these types:
int64
float64
bool
[]byte
string
time.Time
Python has analogous methods object.__getstate__() and object.__setstate__(state).
Reflection¶
The documentation does not mention any restrictions on accessing protected data structure attributes via reflection.
However, using reflection in production for such purposes is not appealing, including for performance reasons. Moreover, this method is essentially yet another way to breach encapsulation.
A similar trick is used here:
package main
import (
"fmt"
"reflect"
"github.com/bitly/go-simplejson"
)
type A struct {
name string `json:"name"`
code string `json:"code"`
}
func marshal(a A) ([]byte, error) {
j := simplejson.New()
va := reflect.ValueOf(&a)
vt := va.Elem()
types := reflect.TypeOf(a)
for i := 0; i < vt.NumField(); i++ {
j.Set(types.Field(i).Tag.Get("json"), fmt.Sprintf("%v", reflect.Indirect(va).Field(i)))
}
return j.MarshalJSON()
}
func main() {
a := A{name: "jessonchan", code: "abc"}
b, _ := marshal(a)
fmt.Println(string(b))
}
Exporter¶
1. Accepting interface (Mediator)¶
1.1. Setter per attribute¶
This approach is discussed in the book “Implementing Domain-Driven Design” by Vaughn Vernon:
Use a Mediator to Publish Aggregate Internal State
To work around the problem of tight coupling between the model and its clients, you may choose to design Mediator [Gamma et al.] (aka Double-Dispatch and Callback) interfaces to which the Aggregate publishes its internal state. Clients would implement the Mediator interface, passing the implementer’s object reference to the Aggregate as a method argument. The Aggregate would then double-dispatch to that Mediator to publish the requested state, all without revealing its shape or structure. The trick is to not wed the Mediator’s interface to any sort of view specification, but to keep it focused on rendering Aggregate states of interest:
public class BacklogItem ... { ... public void provideBacklogItemInterest(BacklogItemInterest anInterest) { anInterest.informTenantId(this.tenantId().id()); anInterest.informProductId(this.productId().id()); anInterest.informBacklogItemId(this.backlogItemId().id()); anInterest.informStory(this.story()); anInterest.informSummary(this.summary()); anInterest.informType(this.type().toString()); ... } public void provideTasksInterest(TasksInterest anInterest) { Set<Task> tasks = this.allTasks(); anInterest.informTaskCount(tasks.size()); for (Task task : tasks) { ... } } ... }The various interest providers may be implemented by other classes, much the same way that Entities (5) describe the way validation is delegated to separate validator classes.
Be aware that some will consider this approach completely outside the responsibility of an Aggregate. Others will consider it a completely natural extension of a well-designed domain model. As always, such trade-offs must be discussed by your technical team members.
Related links:
“More on getters and setters” by Allen Holub
“Save and load objects without breaking encapsulation” at Stackoverflow
The idea can also be seen in the following example:
import java.util.Locale;
public class Employee
{ private Name name;
private EmployeeId id;
private Money salary;
public interface Exporter
{ void addName ( String name );
void addID ( String id );
void addSalary ( String salary );
}
public interface Importer
{ String provideName();
String provideID();
String provideSalary();
void open();
void close();
}
public Employee( Importer builder )
{ builder.open();
this.name = new Name ( builder.provideName() );
this.id = new EmployeeId( builder.provideID() );
this.salary = new Money ( builder.provideSalary(),
new Locale("en", "US") );
builder.close();
}
public void export( Exporter builder )
{ builder.addName ( name.toString() );
builder.addID ( id.toString() );
builder.addSalary( salary.toString() );
}
//...
}
Implementation example in Golang:
package grade_1
import (
"time"
)
type Exporter[T any] interface {
Export(ex func(T))
}
type Exportable[T any] interface {
Export(Exporter[T])
}
type ExportableUint uint
func (e ExportableUint) Export(ex func(uint)) {
ex(uint(e))
}
type MemberId ExportableUint
type Grade ExportableUint
type EndorsementCount ExportableUint
type EndorserExporterSetter interface {
SetId(MemberId)
SetGrade(Grade)
SetAvailableEndorsementCount(EndorsementCount)
SetPendingEndorsementCount(EndorsementCount)
SetVersion(uint)
SetCreatedAt(time.Time)
}
type UintExporter uint
func (e *UintExporter) SetState(value uint) {
*e = UintExporter(value)
}
type Endorser struct {
id MemberId
grade Grade
availableEndorsementCount EndorsementCount
pendingEndorsementCount EndorsementCount
version uint
createdAt time.Time
}
func (e Endorser) Export(ex EndorserExporterSetter) {
ex.SetId(e.id)
ex.SetGrade(e.grade)
ex.SetAvailableEndorsementCount(e.availableEndorsementCount)
ex.SetPendingEndorsementCount(e.pendingEndorsementCount)
ex.SetVersion(e.version)
ex.SetCreatedAt(e.createdAt)
}
type EndorserExporter struct {
Id uint
Grade uint
AvailableEndorsementCount uint
PendingEndorsementCount uint
Version uint
CreatedAt time.Time
}
func (ex *EndorserExporter) SetId(val MemberId) {
val.Export(func(v string) { ex.Id = v })
}
func (ex *EndorserExporter) SetGrade(val Grade) {
val.Export(func(v string) { ex.Grade = v })
}
func (ex *EndorserExporter) SetAvailableEndorsementCount(val EndorsementCount) {
val.Export(func(v string) { ex.AvailableEndorsementCount = v })
}
func (ex *EndorserExporter) SetPendingEndorsementCount(val EndorsementCount) {
val.Export(func(v string) { ex.PendingEndorsementCount = v })
}
func (ex *EndorserExporter) SetVersion(val uint) {
ex.Version = val
}
func (ex *EndorserExporter) SetCreatedAt(val time.Time) {
ex.CreatedAt = val
}
This is an excellent approach, but it uses interfaces, and this turns out to be somewhat verbose – it requires declaring the type (struct) itself, the interface, and setters.
One of the features of this approach is maintaining backward compatibility of the interface when removing an attribute from the aggregate. It also allows for the creation of methods for optional attributes with a default value, such as tenant_id, which can be convenient for developing plugins, extensions, and libraries.
Note that the exporter methods accept Value Objects:
func (ex *EndorserExporter) SetId(val MemberId) {
val.Export(func(v string) { ex.Id = v })
}
We could eliminate this excessive awareness as follows:
func (e Endorser) Export(ex EndorserExporterSetter) {
e.id.Export(ex.SetId)
...
}
...
func (ex *EndorserExporter) SetId(val uint) {
val.Export(func(v string) { ex.Id = v })
}
It seems the degree of awareness has decreased. But there is a flip side. Suppose the Value Object Id becomes composite. We would need to change not only the Value Object exporter interface, but also the Aggregate exporter interface itself. It now has two reasons to change. Likewise, two reasons for change appear in the Aggregate’s export logic itself. This violates the SRP.
[UPDATE]
Actually, this is not necessary if the method returns an exporter for primitive values the same way as for composite values:
func (e Endorser) Export(ex EndorserExporterSetter) {
e.id.Export(ex.SetId())
...
}
...
func (ex *EndorserExporter) SetId() func(uint) {
return func(v string) { ex.Id = v }
}
The second problem is that for auto-increment PKs we need access to the Id.Scan(any) method. And in the last approach it is not available, meaning we would need to add a public Aggregate method to access it.
The third problem is that sometimes we need access to the Value Object itself, for example, when implementing the Specification Pattern.
The fourth problem is maintaining consistency between the exporter and importer interfaces, since when we create an Aggregate, we pass Value Objects to its constructor, not primitive values.
This matters in programming languages that lack package-level visibility, where the Aggregate must provide an importer interface.
What should the importer provide, primitive types or Value Objects?
A FACTORY used for reconstitution is very similar to one used for creation, with two major differences.
An ENTITY FACTORY used for reconstitution does not assign a new tracking ID. To do so would lose the continuity with the object’s previous incarnation. So identifying attributes must be part of the input parameters in a FACTORY reconstituting a stored object.
A FACTORY reconstituting an object will handle violation of an invariant differently. During creation of a new object, a FACTORY should simply balk when an invariant isn’t met, but a more flexible response may be necessary in reconstitution. If an object already exists somewhere in the system (such as in the database), this fact cannot be ignored. Yet we also can’t ignore the rule violation. There has to be some strategy for repairing such inconsistencies, which can make reconstitution more challenging than the creation of new objects.
Figures 6.16 and 6.17 (on the next page) show two kinds of reconstitution. Object-mapping technologies may provide some or all of these services in the case of database reconstitution, which is convenient. Whenever there is exposed complexity in reconstituting an object from another medium, the FACTORY is a good option.
—“Domain-Driven Design: Tackling Complexity in the Heart of Software” by Eric Evans, Chapter “Six. The Life Cycle of a Domain Object :: Factories”
1.2. Batch setter per node¶
There is also a more concise example:
interface PersonImporter {
public int getAge();
public String getId();
}
interface PersonExporter {
public void setDetails(String id, int age);
}
class Person {
private int age;
private String id;
public Person(PersonImporter importer) {
age = importer.getAge();
id = importer.getId();
}
public void export(PersonExporter exporter) {
exporter.setDetails(id, age);
}
}
The second of the examples above contains a batched setter, which makes it somewhat less verbose.
However, in this case, it is not possible to traverse the hierarchy of nested
Aggregate composite nodes with a single exporter instance due to
method name collision with setDetails,
for example, when traversing an Aggregate and its composite primary key
(though in the previous approach, collisions are not entirely excluded either).
A single exporter instance could have been convenient for composing SQL query parameter lists.
One way to resolve the collision issue is to suffix the setter methods with the node type name:
set_[aggregate_type_name](...),set_[entity_type_name](...),set_[composite_value_object_type_name](...),…
In this case, the exporter becomes very similar to Visitor Pattern.
Unlike the previous approach, maintaining backward compatibility of the interface when removing an attribute from an aggregate depends on the syntactic capabilities of the programming language, whether it allows specifying keyword arguments with a default value.
Testing¶
Using interface-based exporter in test cases makes them somewhat more verbose.
One could argue that testing should follow black-box principles, i.e., only external behavior. Absolutely true, but we need not only external behavior, but also verification that the data passed to the Aggregate constructor is correctly persisted in the database.
💬️ “It has long been known that testability is an attribute of good architectures. The Humble Object pattern is a good example, because the separation of the behaviors into testable and non-testable parts often defines an architectural boundary. The Presenter/View boundary is one of these boundaries, but there are many others.”
—“Clean Architecture: A Craftsman’s Guide to Software Structure and Design” by Robert C. Martin
2. Returning structure¶
As an alternative, Aggregate can simply return a plain structure, and such approaches also appear in demo applications, for example, here.
💬️ “The goal of software architecture is to minimize the human resources required to build and maintain the required system.”
—“Clean Architecture: A Craftsman’s Guide to Software Structure and Design” by Robert C. Martin
It becomes practical to simplify the export method, giving it the signature
Endorser.Export() EndorserState
instead of
Endorser.ExportTo(ex EndorserExporter).
Python even has documented methods __getstate__() and __setstate__() for this purpose.
The result is something like a DTO, with the only difference being that it crosses not network boundaries, but the Aggregate’s encapsulation boundaries.
Robert C. Martin wrote about the same principle:
💬️ “Presenters are a form of the Humble Object pattern, which helps us identify and protect architectural boundaries.”
💬️ “Typically the data that crosses the boundaries consists of simple data structures. You can use basic structs or simple data transfer objects if you like. Or the data can simply be arguments in function calls. Or you can pack it into a hashmap, or construct it into an object. The important thing is that isolated, simple data structures are passed across the boundaries. We don’t want to cheat and pass Entity objects or database rows. We don’t want the data structures to have any kind of dependency that violates the Dependency Rule.
For example, many database frameworks return a convenient data format in response to a query. We might call this a “row structure.” We don’t want to pass that row structure inward across a boundary. Doing so would violate the Dependency Rule because it would force an inner circle to know something about an outer circle.
Thus, when we pass data across a boundary, it is always in the form that is most convenient for the inner circle.”
💬️ “It also uses the DataAccessInterface to bring the data used by those Entities into memory from the Database. Upon completion, the UseCaseInteractor gathers data from the Entities and constructs the OutputData as another plain old Java object. The OutputData is then passed through the OutputBoundary interface to the Presenter.”
—“Clean Architecture: A Craftsman’s Guide to Software Structure and Design” by Robert C. Martin
This approach is demonstrated in the Golang DDD ES/CQRS Reference Application by EventStore contributors.
Nick Tune demonstrates the same approach in the sample code for his book. Moreover, he applies it even for Value Object.
package grade_2
import (
"time"
)
type Exportable[T any] interface {
Export() T
}
type ExportableUint uint
func (e ExportableUint) Export() uint {
return uint(e)
}
type MemberId ExportableUint
type Grade ExportableUint
type EndorsementCount ExportableUint
type EndorserState struct {
Id uint
Grade uint
AvailableEndorsementCount uint
PendingEndorsementCount uint
Version uint
CreatedAt time.Time
}
type Endorser struct {
id MemberId
grade Grade
availableEndorsementCount EndorsementCount
pendingEndorsementCount EndorsementCount
version uint
createdAt time.Time
}
func (e Endorser) Export() EndorserState {
return EndorserState{
e.id.Export(), e.grade.Export(),
e.availableEndorsementCount.Export(),
e.pendingEndorsementCount.Export(),
e.version, e.createdAt,
}
}
A disadvantage of this solution that I have noticed is that the client has no ability to define the structure of the exported object, unlike the interface-based approach.
This makes it difficult to create generic classes, such as a generic composite primary key. As a result, intermediate structures proliferate that then need to be converted to the required form.
Along with the data, the data hierarchy is also exported, i.e., the Aggregate’s internal structure. This means that traversing the structure is no longer the Aggregate’s responsibility in a single place, but rather the responsibility of exported data consumers in multiple places, increasing the cost of program changes.
Backward compatibility becomes more difficult, since the state is singular while behavior is multiple, meaning it is versionable.
Knowledge of the return type pushes toward using generics where it could be avoided.
The returned structure and its typing constitute excessive knowledge that can hinder generalization (abstraction) of this method’s client, for example, preventing the extraction of an abstract Repository pattern class.
Instead of a structure, an array/slice of driver.Value typed objects would be much more convenient.
This is yet another argument in favor of the intereface-based approach with separate setters for each Aggregate attribute.
State Import¶
In Golang, struct visibility is accessible to the entire package, so there is no great need to implement Importer/Provider - it is sufficient to place the Reconstitutor in the same package.
In other languages, it may be necessary to create an Importer/Provider, which creates a breach in encapsulation. Therefore, state import usually is implemented either via a constructor, if multiple dispatch (overloading) is supported, or via a static class method - so that creation is possible but modification is not. However, this creates a difficulty with synchronizing object state in the IdentityMap during commit, since the Aggregate state is now inaccessible for synchronization. In such a case, the only option is to clear the IdentityMap on commit.
Export state of Immutable Types¶
Aggregates and Entities are mutable, so encapsulation guarantees invariant protection when their state changes. But do we need exporters for immutable types, such as Value Objects or Domain Events?
Nick Tune uses an exporter even for Value Object.
The export method should not depend on the scope of visibility or accessibility of the Value Object’s value, which may change over time, as may the Value Object’s structure itself. Otherwise, this introduces fragility into the program.
Composite and simple Value Objects should be handled uniformly.
Greg Young on exporting Domain Event state:
💬 This table represents the actual Event Log. There will be one entry per event in this table. The event itself is stored in the [Data] column. The event is stored using some form of serialization, for the rest of this discussion the mechanism will assumed to be built in serialization although the use of the memento pattern can be highly advantageous.
Decision¶
The decision is to use a uniform state export method for Aggregates, Value Objects and Domain Events via the Accepting interface (Mediator).
The increase in code volume is not critical due to “ADR-0007: Code Generation over ORM”.
Consequences¶
Preserved encapsulation: Aggregate internals are never directly exposed; all state access goes through explicit export/import interfaces.
Uniform export mechanism: Aggregates, Value Objects and Domain Events all use the same Accepting interface (Mediator) pattern, reducing cognitive load.
Portability: the approach works equally well in Golang (where package-level visibility helps) and in Python (where it enforces discipline beyond naming conventions). See ADR-0003: Go Portability Considerations.
Scaffold-friendly: the verbose boilerplate (exporter interfaces, setters) is generated automatically by the scaffold module. See ADR-0007: Code Generation over ORM.
Testability: exporter interfaces enable black-box verification of persisted state without accessing protected attributes.
Trade-off – verbosity: each Aggregate requires an exporter interface and corresponding setter methods, which adds code volume compared to direct attribute access or returning plain structures.
Related¶
ADR-0003: Go Portability Considerations – cross-language portability constraints
ADR-0007: Code Generation over ORM – code generation to offset exporter boilerplate