Clean DDD lessons: audit metadata for domain entities

George
Technical blog from UNIL engineering teams
6 min readDec 15, 2023
Photo by Angèle Kamp on Unsplash

In this lesson we look in details how we can keep track of the changes done to our domain entities — otherwise known as auditing.

Auditing — a first-class DDD concern

Auditing consist of keeping track of who and when has created an entity in our system or has changed its state. It usually involves at least these four attributes: createdBy , createdDate , lastModifiedBy , and lastModifiedDate which we need to record for each entity.

One important thing to realize is that auditing of entities in DDD is a genuine business concern. It means that it should not be left to the discretion of the persistence adapter. There are popular mechanisms for auditing the persistence entities automatically. Like JPA Auditing, for example. We should not, however, rely on these techniques. And this is for the following reasons:

  • Knowing who and when has changed a domain entity is bound sooner or later to enter into a consideration when performing business logic of our application. This means that we must approach this concern as a firs-class modeling exercice: exactly as we would with any other attributes of our domain entities.
  • If we rely on automatic mechanism, provided by the persistence adapter, we will have to introduce coupling between that adapter and the security adapter: this is because we need to obtain the information about the auditor — the user of the system currently manipulating the entity. In Clean Architecture, however, we should not have any dependencies between adapters.
  • A domain aggregate is a graph of objects (some entities, some Value Objects) and, as such, it often maps to several persistence entities and, consequently, several different rows in different tables in the database. So, what constitutes a change in a particular entity in the aggregate, in the business logic sense, may not always automatically need to result in an update of the audit information in the database.

Introducing audit metadata Value Object

Keeping in mind the considerations presented above, we now look at an example way we can implement auditing for our domain entities. In what follows, we shall use the persistence schema described in this post. We begin by creating a Value Object, AuditMetadata , which will encapsulate the four properties: the ID of the creator and the time of creation, as well, as the ID of the user who modified an entity last, together with the time of the modification.

@Value
public class AuditMetadata {

public static AuditMetadata createdBy(PersonId auditUser) {
Instant now = Instant.now();
return AuditMetadata.builder()
.createdBy(auditUser)
.createdDate(now)
.lastModifiedBy(auditUser)
.lastModifiedDate(now)
.build();
}

@Builder
public AuditMetadata(PersonId createdBy, Instant createdDate, PersonId lastModifiedBy, Instant lastModifiedDate) {
this.createdBy = notNull(createdBy);
this.createdDate = notNull(createdDate);
this.lastModifiedBy = notNull(lastModifiedBy);
this.lastModifiedDate = notNull(lastModifiedDate);
}

PersonId createdBy;

Instant createdDate;

PersonId lastModifiedBy;

Instant lastModifiedDate;

/**
* Returns a copy of this audit metadata with {@code lastModifiedBy}
* updated to the given argument and {@code lastModifiedDate} updated to
* the "now".
*
* @param lastModifiedBy unique ID of the user to use for auditing information
* @return copy of audit metadata with updated {@code lastModifiedBy} and {@code lastModifiedDate}
*/
public AuditMetadata lastModifiedBy(PersonId lastModifiedBy) {
return newAuditMetadata()
.lastModifiedBy(lastModifiedBy)
.lastModifiedDate(Instant.now())
.build();
}

AuditMetadataBuilder newAuditMetadata() {
return AuditMetadata.builder()
.createdBy(createdBy)
.createdDate(createdDate)
.lastModifiedBy(lastModifiedBy)
.lastModifiedDate(lastModifiedDate);
}

}

PersonId in the above snippet refers to a Value Object incapsulating ID of a user in the system. AuditMetadata object is immutable, when we are required to register an audit record of a modification, we simply return a new instance of AuditMetadata with new values of lastModifiedBy and lastModifiedDate attributes.

Persisting audit metadata

Here is how a corresponding persistent entity will look like:

@Data
@FieldDefaults(level = AccessLevel.PRIVATE)
public class AuditMetadataDbEntity {

@Column("created_by")
String createdBy;

@Column("created_date")
Instant createdDate;

@Column("last_modified_by")
String lastModifiedBy;

@Column("last_modified_date")
Instant lastModifiedDate;

}

A very straightforward mapping from AuditMetadata to and from AuditMetadtaDbEntity should be registered with the mapper responsible for converting domain entities to their corresponding DB persistent entities. Here is an how AuditMetadataDbEntity is referenced from other persistent entities:

@Data
@Table("person")
@FieldDefaults(level = AccessLevel.PRIVATE)
@EqualsAndHashCode
public class PersonDbEntity {

@Id
@Column("id")
String id;

@Column("first_name")
String firstName;

@Column("last_name")
String lastName;

@Column("date_of_birth")
Instant dateOfBirth;

@Column("email")
String email;

@Version
Integer version;

@Embedded.Nullable
AuditMetadataDbEntity auditMetadata;

}

Please, note the use of Embedded annotation on the auditMetadata property. This will instruct Spring Data JDBC to embed the four auditing properties in the same table together with other properties of Person . This technique has an advantage of not using any particular mechanism, like JPA Auditing, which may be difficult to port to other persistence schema, if required. The domain entities themselves each have a reference to AuditMetadata Value Object.

For the sake of completeness, we give below an SQL snippet needed to create a table for persisting PersonDbEntity together with AuditMetadataDbEntity :

CREATE TABLE public.person
(
id varchar NOT NULL,
first_name varchar NOT NULL,
last_name varchar NOT NULL,
date_of_birth timestamp NOT NULL,
email varchar NOT NULL,
"version" int NULL,

created_date timestamp NULL,
created_by varchar NULL,
last_modified_date timestamp NULL,
last_modified_by varchar NULL,

CONSTRAINT person_pk PRIMARY KEY (id)
);

Using audit metadata object

Let us now look at how AuditMetadata is used in business logic processing. Here is the relevant snippet from Person aggregate root. We examine a business method which updates the email of a person.

@Getter
@FieldDefaults(makeFinal = true, level = AccessLevel.PRIVATE)
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
public class Person {

@EqualsAndHashCode.Include
PersonId id;

String firstName;

String lastName;

LocalDate dateOfBirth;

String email;

Integer version;

AuditMetadata auditMetadata;

@Builder
public Person(PersonId id, String firstName, String lastName, LocalDate dateOfBirth, String email,
Integer version, AuditMetadata auditMetadata) {
this.id = notNull(id);
this.firstName = notBlank(firstName);
this.lastName = notBlank(lastName);
this.dateOfBirth = notNull(dateOfBirth);
this.email = notNull(email);

this.auditMetadata = auditMetadata;
this.version = version;

checkInvariants();
}

// some code is omitted

PersonBuilder newPerson() {
return Person.builder()
.id(id)
.firstName(firstName)
.lastName(lastName)
.dateOfBirth(dateOfBirth)
.email(email)
.version(version)
.auditMetadata(auditMetadata);
}

/**
* Updates {@code email} of this person.
*
* @param newEmail new email
* @param auditorId person ID of the user who requests the update
* @return new instance of {@code Person} with email updated
*/
public Person updateEmail(String newEmail, PersonId auditorId) {
return newPerson()
.email(newEmail)
.auditMetadata(auditMetadata.lastModifiedBy(auditorId))
.build();
}
}

In the best tradition of Clean DDD approach, Person is an immutable entity. When we are updating the email of an instance of Person , we are actually creating and returning a new instance of Person with the updated email and the updated audit metadata. Note that for the update of the audit metadata we just need to pass the ID of the auditor. Generally speaking we may stipulate the following:

Every business method which modifies the state of a domain entity will need to have at least one extra parameter— the ID of the user or auditor who requests the change. This ID should be used to record new auditing information which should be recorded together with the actual change of the state of the entity.

It remains to be seen how we actually get the ID of the auditor before we invoke a business method on an aggregate. For this, of course, we need to examine the relevant use case.

class EditPersonInfoUseCase extends EditPersonInfoInputPort {

EditPersonInfoPresenterOutputPort presenter;
PersistenceOperationsOutputPort persistenceOps;
SecurityOperationsOutputPort securityOps;

@Transactional
@Override
public void changeEmail(PersonId personId, String newEmail){

// some code omitted

String oldEmail;
try {

// load aggregate
Person person = persistenceOps.findPersonById(personId);
oldEmail = person.getEmail();

// get current user ID from the security port
PersonId auditorId = securityOps.authenticatedUserId();

// do some relevant domain security checks
securityOps.assertUserCanEditPersonInfo(person);

// perform update of the email
Person personWithUpdatedEmail = person.updateEmail(newEmail, auditorId);

// persist state change
persistenceOps.savePerson(personWithUpdatedEmail);
}
catch (Exception e){
presenter.presentError(e);
return;
}

presenter.presentResultOfSuccessfulEmailUpdate(oldEmail);
}

}

Please, note how it is the responsibility of the use case to talk to the security operations output port to get the ID of the auditor — the ID of the currently authenticated user. This is the right way, following the precepts of Clean Architecture, to provide auditor ID to the business method of the aggregate. In doing so, we are clearly separating the adapters for persistence and security related operations.

Conclusion

We have looked in details of how we can implement keeping track of audit information for our domain entities. We have argued that auditing should be dealt with like any other DDD concern. This requires us to model carefully an abstraction used to record and modify audit metadata during the update of the state of a domain entity. We have looked at the example of AuditMetadata Value Object, how it can be persisted, and how it is involved in the business logic processing.

--

--