Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,10 @@ from committed configuration.
and no status enum — path, authorization and response shape stay with the application.
- **Lifecycle Events** — `OnObjectCreate`, `OnObjectUpdate` and `OnObjectDelete` are published
synchronously inside the originating transaction; consumers opt into post-commit behaviour
with `@TransactionalEventListener`.
with `@TransactionalEventListener`. `OnObjectUpdate` carries the state before the update
(`getPrevious()`) when the publisher knows it, and the data services publish no event for an
update that changed nothing. Typed listeners (`OnObjectUpdate<BookDto>`) receive only their own
DTO type.
- **Application Build & SBOM Info** — `ApplicationInfoService` answers *"which build is running,
and what is it made of?"* from build-produced metadata: artifact coordinates and Git commit
(from Spring Boot's `BuildProperties` / `GitProperties`) plus a parsed CycloneDX SBOM.
Expand Down
93 changes: 92 additions & 1 deletion docs/releases/upgrade-to-1.5.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@
> **Draft — 1.5.0 is unreleased.** This document is written as the release takes shape; sections are
> added as features land. Everything described here is on `main` today.

`spring-services` 1.5.0 brings one change so far. Every section below stands on its own — apply only
`spring-services` 1.5.0 brings two changes so far. Every section below stands on its own — apply only
the ones you need:

| Change | Spec | Nature |
| --- | --- | --- |
| Optional **object store** `ObjectStore` | — | new optional module, off by default, no schema change |
| **Previous state on `OnObjectUpdate`**, no-op updates suppressed, typed listeners fixed | 022 | source break for `postSave` overrides, behaviour change, no schema change |

## Object store (`spring-services-storage`)

Expand Down Expand Up @@ -177,3 +178,93 @@ Both are unchecked.
- **The AWS SDK comes along even if you only use `type=file`.** `software.amazon.awssdk:s3` is a
hard dependency of the module today. Making it optional is planned; until then, budget for it in
your dependency footprint.

---

## Previous state on update events (spec 022)

`OnObjectUpdate` now carries the state **before** the update when the publisher knows it.
`AbstractDbBackedDataService` and `AbstractMultitenantDbBackedDataService` always supply it.
Publishers that cannot know it (for example data from an external system) keep using
`new OnObjectUpdate<>(current)`:

```java
@EventListener
public void onTaskUpdated(OnObjectUpdate<TaskDto> event) {
if (!event.isPreviousKnown()) {
return; // or: treat as "something changed"
}
final TaskDto previous = event.getPrevious().orElseThrow();
final TaskDto current = event.getData();
if (!previous.columnId().equals(current.columnId())) { /* moved */ }
}
```

The event computes no diff and carries no actor. Compare the fields you care about yourself, and
resolve the actor through `AuthService`, as `AuditLogEventListener` does.

This file is a self-contained prompt for an agent to run inside a consumer repo.

### Prompt

You are working inside a Spring Boot service that depends on `spring-services` 1.5.0.

#### 1. Replace `postSave` overrides

`postSave(boolean insert, D data)` no longer exists on either data service base class. Any
`@Override` of it now fails to compile. Split it:

| Before | After |
| --- | --- |
| `postSave(true, data)` branch | `protected void postInsert(D data)` |
| `postSave(false, data)` branch | `protected void postUpdate(D previous, D current)` |

Call `super.postInsert(...)` / `super.postUpdate(...)` wherever the old override called
`super.postSave(...)`, otherwise the lifecycle events are no longer published.

#### 2. Make `toData` / `mapToData` return a snapshot

On update, the base class calls `toData(entity)` **before** `updateEntity` mutates the entity, to
capture the previous state. A DTO that holds the entity's own collection changes along with the
entity, so the previous state becomes equal to the new one and the update event is **silently
dropped** (see step 3). Search every `toData` / `mapToData` for collections passed through
unchanged and copy them:

```java
// wrong: shares the entity's mutable set
return new TaskDto(entity.getId(), entity.getAssignees());
// right
return new TaskDto(entity.getId(), Set.copyOf(entity.getAssignees()));
```

Mapping to a new collection (`stream().map(...).toList()`) is already a copy.

#### 3. Expect no event for no-op updates

An update where `previous.equals(current)` no longer publishes `OnObjectUpdate`. As a result, no
audit-log row is written and no webhook fires for it. Remove any listener code that only filtered
these events out. DTOs that are not records and do not implement `equals` are never equal and
behave as before.

#### 4. Check typed listeners

Before 1.5.0, a listener declared for `OnObjectUpdate<BookDto>` (or `OnObjectCreate<…>` /
`OnObjectDelete<…>`) was invoked for **every** DTO type, because Spring could not resolve the
type parameter. The events now implement `ResolvableTypeProvider`, so such listeners receive only
their own type. Search for listeners that relied on the old behaviour: a typed parameter combined
with an `event.getType()` check for a *different* type. Move them to `GenericDataEvent<?>`.
Listeners on `GenericDataEvent<?>` or raw types are unaffected.

#### 5. Optional: replace hand-written snapshots

If the application snapshots an entity before `save(...)` to diff it afterwards (like open-tasks'
`TaskSnapshot`), it can listen to `OnObjectUpdate` and compare `getPrevious()` with `getData()`
instead.

### Guard rails / Don't do this

- Do not treat `isPreviousKnown() == false` as "nothing changed". It means the publisher did not
know the previous state.
- Do not persist `getPrevious()` without checking it for personal data that the update just
removed.

120 changes: 120 additions & 0 deletions docs/specs/022-update-event-previous-state/behaviors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
# Behaviors: Previous state on update events

Scenarios are unit-level unless marked **[context]** (a real Spring `ApplicationContext` dispatching
the event).

## `OnObjectUpdate` construction

### An update event without a previous state

- **Given** a DTO `current`
- **When** `new OnObjectUpdate<>(current)` is created
- **Then** `getData()` returns `current`
- **And** `isPreviousKnown()` is `false`
- **And** `getPrevious()` is empty

### An update event with a previous state

- **Given** two DTOs `previous` and `current` of the same class with the same id
- **When** `new OnObjectUpdate<>(previous, current)` is created
- **Then** `getData()` returns `current`
- **And** `isPreviousKnown()` is `true`
- **And** `getPrevious()` contains `previous`

### A previous state equal to the current state is accepted

- **Given** `previous` and `current` that are equal
- **When** `new OnObjectUpdate<>(previous, current)` is created
- **Then** the event is created — suppressing no-ops is not the event's job

### A null previous state is rejected

- **When** `new OnObjectUpdate<>(null, current)` is created
- **Then** a `NullPointerException` is thrown

### A previous state with a different id is rejected

- **Given** `previous` and `current` of the same class with different ids
- **When** `new OnObjectUpdate<>(previous, current)` is created
- **Then** an `IllegalArgumentException` is thrown

### A previous state of a different class is rejected

- **Given** `previous` and `current` of different classes with the same id
- **When** `new OnObjectUpdate<>(previous, current)` is created
- **Then** an `IllegalArgumentException` is thrown

## Typed listeners

### The resolvable type carries the DTO type

- **Given** an `OnObjectUpdate` for a `BookDto`
- **When** `getResolvableType()` is called
- **Then** the result is `OnObjectUpdate<BookDto>`

### A subclass that fixes the type parameter still resolves

- **Given** an application event `class BookUpdated extends OnObjectUpdate<BookDto>`
- **When** `getResolvableType()` is called on an instance
- **Then** the result is `BookUpdated` and no exception is thrown

### A typed listener ignores events for other DTO types [context]

- **Given** a listener method `onBook(OnObjectUpdate<BookDto>)`
- **When** an `OnObjectUpdate<AuthorDto>` is published
- **Then** `onBook` is not invoked

### A typed listener receives events for its DTO type [context]

- **Given** a listener method `onBook(OnObjectUpdate<BookDto>)`
- **When** an `OnObjectUpdate<BookDto>` is published
- **Then** `onBook` is invoked once with that event

### A listener on the base type receives every DTO type [context]

- **Given** a listener method `onAny(GenericDataEvent<?>)`
- **When** an `OnObjectUpdate<BookDto>` and an `OnObjectCreate<AuthorDto>` are published
- **Then** `onAny` is invoked for both

## `AbstractDbBackedDataService`

### An insert publishes a create event

- **When** `save(dto)` is called with a DTO without id
- **Then** exactly one `OnObjectCreate` carrying the saved DTO is published

### An update publishes previous and current state

- **Given** a persisted entity whose name is `"old"`
- **When** `save(dto)` is called with the same id and name `"new"`
- **Then** exactly one `OnObjectUpdate` is published
- **And** its `getPrevious()` has name `"old"`
- **And** its `getData()` has name `"new"`

### An update that changes nothing publishes no event

- **Given** a persisted entity whose name is `"same"`
- **When** `save(dto)` is called with the same id and name `"same"`
- **Then** no event is published
- **And** the saved DTO is returned as usual

### Events stay suppressed when publishing is disabled

- **Given** a service constructed with `publishEvents == false`
- **When** an insert, a real update and a delete are performed
- **Then** no event is published

## `AbstractMultitenantDbBackedDataService`

### An update publishes previous and current state

- **Given** a persisted entity of the current tenant whose name is `"old"`
- **When** `save(dto)` is called with the same id and name `"new"`
- **Then** exactly one `OnObjectUpdate` is published with previous name `"old"` and current name
`"new"`

### An update that changes nothing publishes no event

- **Given** a persisted entity of the current tenant whose name is `"same"`
- **When** `save(dto)` is called with the same id and name `"same"`
- **Then** no event is published
Loading
Loading