Loading...

Additional auditing with Spring Data JPA and Hibernate

JPA
January 28, 2020
4 minutes to read
Share this post:

Spring Data provides an easy way of keeping track who creates and modifies a persistent entity as well as when the action happened by annotating properties with @CreatedBy, @CreatedDate, @LastModifiedBy and @LastModifiedDate. The properties are automatically provided by an implementation of the AuditAware and DateTimeProvider interface.

Beside this common auditing information in my current projects some entities require storing auditing-information about crucial state-changes like soft-deletion.

Previously we had to fill the properties ourself:

@Entity
public class SomeEntity {
	@Column
	private String deletedBy;
	@Column
	private OffsetDateTime deletedOn;

	public void markAsDeleted(String currentUser, OffsetDateTime currentDateTime) {
		this.deletedBy = currentUser;
		this.deletedOn = currentDateTime;
	}
}

public class SomeService {
	
	private CurrentUserProvider currentUserProvider;
	private CurrentDateTimeProvider currentDateTimeProvider;

	public void markAsDeleted(SomeEntity someEntity) {
		String currentUser = currentUserProvider.currentUser();
		OffsetDateTime currentDateTime = currentDateTimeProvider.now();

		someEntity.markAsDeleted(currentUser, currentDateTime);
	}
}

This is of course not a big thing but anyway: Why do we have to do it? Why can’t we simply use the same ‘magic’ that is working for the regular auditing properties?

Basic Idea

So the simplest approach is to use @PreUpdate to fill the additional auditing properties. To avoid updating them on each update of our entity we use a transient boolean.

@Entity
@EntityListeners(DeletionListener.class)
public class SomeEntity {
	@Column
	private String deletedBy;
	@Column
	private OffsetDateTime deletedOn;

	public transient boolean markedForDeletion = false;

	public void markAsDeleted() {
		this.markedForDeletion = true;
	}

	public void fillDeletion(String currentUser, OffsetDateTime currentDateTime) {
		this.deletedBy = currentUser;
		this.deletedOn = currentDateTime;
	}
}

public class DeletionListener {
	
	private CurrentUserProvider currentUserProvider;
	private CurrentDateTimeProvider currentDateTimeProvider;

	@PreUpdate
	public void touchForUpdate(Object target) {
		SomeEntity entity = (SomeEntity) target;
		if(entity.markedForDeletion) {
			String currentUser = currentUserProvider.currentUser();
			OffsetDateTime currentDateTime = currentDateTimeProvider.now();

			someEntity.fillDeletion(currentUser, currentDateTime);
		}
	}
}

This has just one problem: @PreUpdate is only called when something on the entity has been changed and an update is necessary. But since markedForDeletion is transient changing its value does not trigger an update.

Simple solution

A quite simple solution is to just change ‘something’ on the entity together with marking it for deletion. To guarantee triggering a PreUpdate while not having additional unnecessary persistent properties we can either change the deletedBy or deletedOn property to some temporary value. The @PreUpdate method will override these values with correct values eventually.

@Entity
@EntityListeners(DeletionListener.class)
public class SomeEntity {
	@Column
	private String deletedBy;
	@Column
	private OffsetDateTime deletedOn;

	public transient boolean markedForDeletion = false;

	public void markAsDeleted() {
		deletedOn = LocalDate.of(1337, 1, 1).atTime(OffsetTime.now());
		this.markedForDeletion = true;
	}
}

Complex solution

A more complex solution is telling Hibernate that the entity has been updated without unnecessarily changing properties. Hibernate facilitates that by supporting a CustomEntityDirtinessStrategy. But this would require us to check the full entity including Collections and other complex property types. We definitely don’t want that! Instead, we can create a CompositeUserType for our additional auditing properties. CompositeUserTypes are checked for dirtiness by calling an equals-method, comparing their current instance with the originally loaded snapshot.

@Entity
@TypeDef(name = "Deletable", typeClass = DeletableType.class, defaultForType = Deletable.class)
@EntityListeners(DeletionListener.class)
public class SomeEntity {
	@Columns(columns = {@Column(name = "deleted_by"), @Column(name = "deleted_on")})
	private Deletable deletable = new Deletable();

	public void markAsDeleted() {
		deletable.markAsDeleted();
	}
}

public class Deletable {
	private UserName deletedBy;
	private OffsetDateTime deletedOn;
	private transient boolean markAsDeleted = false;

	public void markAsDeleted() {
		markAsDeleted = true;
	}

	public void fillDeletion(String currentUser, OffsetDateTime currentDateTime) {
		this.deletedBy = currentUser;
		this.deletedOn = currentDateTime;
	}
}

public class DeletableType implements CompositeUserType {
	@Override
	public boolean equals(Object x, Object y) {
		if (x == y) {
			return true;
		}
		if (y == null || x.getClass() != y.getClass()) {
			return false;
		}
		Deletable xDeletable = (Deletable) x;
		Deletable yDeletable = (Deletable) y;
		return xDeletable.markAsDeleted == yComponent.markAsDeleted &&
				Objects.equals(xDeletable.deletedBy, yDeletable.deletedBy) &&
				Objects.equals(xDeletable.deletedOn, yDeletable.deletedOn);
	}

	@Override
	public int hashCode(Object x) {
		Deletable deletable = (Deletable) x;
		return Objects.hash(deletable.deletedBy, deletable.deletedOn, deletable.markAsDeleted);
	}
}

Summary

So both solutions have their drawbacks. On the one hand it is very technical and not domain-driven to have these temporary values. On the other it’s a tad on the over-engineering side to provide a CompositeUserType for something as simple as 2 auditing-fields.
Anyways, there are examples enough for those auditing-requirements. Examples include by whom or when some some email was sent, a file was downloaded or that process was triggered.

So which solution do you prefer?
Personally I would choose the complex one as it is cleaner.

About the Author

After studying computer science in Ravensburg Sascha worked several years on online games as a backend developer. Recently his focus is on developing custom software for Sport Alliance GmbH in Hamburg.

Have you heard of Marcus' Backend Newsletter?

New ideas. Twice a week!
Top