Skip to content

Tentacolous Documentation

Technical guide

Everything you need to install, configure and operate Tentacolous in a Spring Boot project.

Learn how to add the dependency, configure event processing and create Java listeners that react to real PostgreSQL changes through triggers, an event table and a Spring poller.

Overview

Tentacolous is a Spring Boot library that runs Java methods when a database table receives an INSERT, UPDATE, or DELETE.

The important difference is that Tentacolous reacts to database changes regardless of where they originate.

1. It creates an event table.

2. It creates a PostgreSQL function.

3. It creates triggers for tables that have listeners.

4. The triggers write events into db_change_event.

5. A Spring poller reads those events.

6. Tentacolous converts the JSON payload into your Java entity.

7. Tentacolous runs the annotated method.

Requirements

  • Java 17 or higher.
  • Spring Boot.
  • A Spring Boot application with a configured DataSource.
  • PostgreSQL for automatic trigger creation.

Automatic database infrastructure creation is currently implemented for PostgreSQL.

Dependency snippets

Gradle

implementation 'io.github.aimtone:tentacolous:0.1.7'

Maven

<dependency>
  <groupId>io.github.aimtone</groupId>
  <artifactId>tentacolous</artifactId>
  <version>0.1.7</version>
</dependency>

Migrating from 0.1.6

Version 0.1.7 adds access to the previous entity and record history. Existing one-parameter listeners continue to work, so adopting the new method signatures is optional.

Update the dependency

<version>0.1.7</version>

Optional update listener signatures

@UponUpdating(entity = Person.class)
public void onPersonUpdated(Person newPerson, Person oldPerson) {
}

@UponUpdating(entity = Person.class)
public void onPersonUpdated(Person newPerson, Person oldPerson, List<Person> history) {
}

Optional delete history

@UponDeleting(entity = Person.class)
public void onPersonDeleted(Person person, List<Person> history) {
}

Database infrastructure

With tentacolous.schema-management=auto, Tentacolous adds the required old_payload and record_key columns and updates its trigger function automatically.

With schema-management=none, update the manually managed event table and trigger function before using previous entities or history. With validate, the application validates the infrastructure but does not modify it.

Application configuration

Tentacolous needs your application to have a database connection. Example application.yml:

tentacolous:
  enabled: true
  schema-management: auto
  event-table: db_change_event
  poll-interval: 1s
  initial-delay: 0s
  batch-size: 100
  max-attempts: 3

spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/mydb
    username: postgres
    password: postgres
    driver-class-name: org.postgresql.Driver

Available properties

Property Default What it does
tentacolous.enabled true Enables or disables Tentacolous.
tentacolous.schema-management auto Defines whether Tentacolous creates, validates, or ignores database infrastructure.
tentacolous.event-table db_change_event Name of the event table.
tentacolous.poll-interval 1s How often pending events are read.
tentacolous.initial-delay 0s Delay before the poller starts.
tentacolous.batch-size 100 Maximum number of events read in each cycle.
tentacolous.max-attempts 3 Retry limit before an event is marked as FAILED.

Schema management modes

Mode Common use Behavior
auto Development Creates the table, function and triggers if needed.
create Controlled development environments Forces supported infrastructure creation.
validate Production Validates that infrastructure exists without creating it.
none Production with migrations Does not create or validate infrastructure.

To get started, use schema-management: auto. In production, validate or none is usually a better choice because creating triggers requires elevated database permissions.

Example entity

Tentacolous uses the entity to know which database table it should listen to.

@Entity
@Table(name = "person")
public class Person {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;
    private String lastname;
    private String email;

    public Person() {
    }

    // getters and setters
}

If the entity has @Table(name = "person"), Tentacolous listens to the person table. If it does not have @Table, Tentacolous infers the table name from the class name.

Entity Inferred table
Person person
UserAccount user_account
PaymentTransaction payment_transaction

That is why the annotations do not have a table parameter: the selected entity already represents the table.

Annotation parameters

The three annotations have the same shape: @UponInserting(...), @UponUpdating(...) and @UponDeleting(...).

Parameter Required Description
entity Yes Entity class that represents the table and receives the deserialized payload.
entityName No Logical event name. If omitted, Tentacolous uses the class simple name.
field Only with filters Payload field to compare.
valueType Only with filters Type used to interpret value.
value Only with filters Expected value, always written as text.
exclude No Columns that should not be stored in the event payload.

About entityName

Most of the time, you do not need entityName. By default, @UponInserting(entity = Person.class) uses Person. The value is stored in db_change_event.entity_name and is used internally to match events with listeners.

Use entityName only for advanced cases where you need a stable logical name, for example when external infrastructure already writes events with a specific name.

About exclude

exclude does not filter listeners. It prevents specific columns from being stored in the event JSON payload.

@UponInserting(
    entity = User.class,
    exclude = {"password", "token", "secret_key"}
)
public void onUserInserted(User user) {
}

This matters because the event table may contain business data. You usually do not want secrets stored there.

Filter types

Filters always use this structure:

field = "fieldName",
valueType = ValueType.TYPE,
value = "expected value"

If you use valueType, you must also define field and value.

ValueType value format Example
STRING Exact text "APPROVED"
BOOLEAN true or false "true"
NUMBER Long integer number "1"
INTEGER Integer number "7"
LONG Long integer number "999"
DECIMAL Exact decimal "10.50"
DOUBLE Floating point decimal "3.14"
DATE ISO date "2026-07-07"
TIME ISO time "13:45:00"
DATETIME ISO instant or datetime "2026-07-07T13:45:00Z"
UUID Canonical UUID "550e8400-e29b-41d4-a716-446655440000"

Operations

UponInserting

@UponInserting runs a method when an INSERT occurs on the entity table.

@UponInserting(entity = Person.class)
public void onPersonInserted(Person person) {
    System.out.println("Inserted: " + person.getEmail());
}

Use cases: run logic after a record is created, send a notification, publish a Kafka message or create an audit record.

Insert with explicit logical name

@UponInserting(entity = Person.class, entityName = "Person")
public void onPersonInserted(Person person) {
}

Insert with STRING filter

@UponInserting(
    entity = Person.class,
    field = "email",
    valueType = ValueType.STRING,
    value = "admin@example.com"
)
public void onAdminInserted(Person person) {
}

Insert with BOOLEAN filter

@UponInserting(
    entity = User.class,
    field = "active",
    valueType = ValueType.BOOLEAN,
    value = "true"
)
public void onActiveUserInserted(User user) {
}

Insert with numeric filter

@UponInserting(
    entity = User.class,
    field = "level",
    valueType = ValueType.LONG,
    value = "1"
)
public void onLevelOneUserInserted(User user) {
}

Insert with date filter

@UponInserting(
    entity = Payment.class,
    field = "paymentDate",
    valueType = ValueType.DATE,
    value = "2026-07-07"
)
public void onPaymentDate(Payment payment) {
}

Insert with excluded columns

@UponInserting(
    entity = User.class,
    exclude = {"password", "token"}
)
public void onUserInserted(User user) {
}

UponUpdating

@UponUpdating runs a method when an UPDATE occurs on the entity table.

@UponUpdating(entity = Person.class)
public void onPersonUpdated(Person person) {
    System.out.println("Updated: " + person.getEmail());
}

You can also receive the previous record values by adding a second parameter of the same entity type.

@UponUpdating(entity = Person.class)
public void onPersonUpdated(Person newPerson, Person oldPerson) {
}

If you add a third parameter, Tentacolous provides the previous snapshots for that same record, ordered from oldest to newest.

@UponUpdating(entity = Person.class)
public void onPersonUpdated(Person newPerson, Person oldPerson, List<Person> history) {
}

The history parameter can be a List<Person> or Person[]. Tentacolous matches the record by its @Id column when available, otherwise by id.

Use cases: recalculate derived data, invalidate cache, notify other systems or synchronize with an external service.

Update with STRING filter

@UponUpdating(
    entity = Payment.class,
    field = "status",
    valueType = ValueType.STRING,
    value = "APPROVED"
)
public void onPaymentApproved(Payment payment) {
}

Update with BOOLEAN filter

@UponUpdating(
    entity = User.class,
    field = "enabled",
    valueType = ValueType.BOOLEAN,
    value = "false"
)
public void onUserDisabled(User user) {
}

Update with DATETIME filter

@UponUpdating(
    entity = Invoice.class,
    field = "paidAt",
    valueType = ValueType.DATETIME,
    value = "2026-07-07T13:45:00Z"
)
public void onInvoicePaidAt(Invoice invoice) {
}

Update with excluded columns

@UponUpdating(
    entity = User.class,
    exclude = {"password", "refresh_token"}
)
public void onUserUpdated(User user) {
}

Excluded columns are removed from both the new and previous payloads.

UponDeleting

@UponDeleting runs a method when a DELETE occurs on the entity table. For a delete, the payload contains the previous values of the record because the row no longer exists after deletion.

@UponDeleting(entity = Person.class)
public void onPersonDeleted(Person person) {
    System.out.println("Deleted: " + person.getEmail());
}

You can also receive the previous snapshots for that same record by adding a second parameter.

@UponDeleting(entity = Person.class)
public void onPersonDeleted(Person person, List<Person> history) {
}

The history parameter can be a List<Person> or Person[].

Use cases: clean up external resources, delete related data in another database, notify about deletions or register deletion audit data.

Delete with STRING filter

@UponDeleting(
    entity = Person.class,
    field = "email",
    valueType = ValueType.STRING,
    value = "admin@example.com"
)
public void onAdminDeleted(Person person) {
}

Delete with UUID filter

@UponDeleting(
    entity = Session.class,
    field = "externalId",
    valueType = ValueType.UUID,
    value = "550e8400-e29b-41d4-a716-446655440000"
)
public void onSessionDeleted(Session session) {
}

Delete with excluded columns

@UponDeleting(
    entity = User.class,
    exclude = {"password", "token"}
)
public void onUserDeleted(User user) {
}

Invalid combinations

These combinations should fail when the application starts.

Missing field

@UponInserting(
    entity = Person.class,
    valueType = ValueType.BOOLEAN,
    value = "true"
)
public void invalid(Person person) {
}

Missing value

@UponInserting(
    entity = Person.class,
    field = "active",
    valueType = ValueType.BOOLEAN
)
public void invalid(Person person) {
}

Method with more than one parameter

@UponInserting(entity = Person.class)
public void invalid(Person person, String other) {
}

Incompatible parameter

@UponInserting(entity = Person.class)
public void invalid(String person) {
}

Manual testing

Assume this entity and listener:

@Entity
@Table(name = "person")
public class Person {
    // ...
}

@UponInserting(entity = Person.class)
public void inserted(Person person) {
    System.out.println("Person inserted: " + person.getEmail());
}

Start your Spring Boot application. Tentacolous should print logs similar to:

Tentacolous registered 1 listener method(s)
Initializing Tentacolous schema using event table 'db_change_event'
Creating Tentacolous INSERT trigger for table 'person' and entity 'Person'
Starting Tentacolous poller

Then run this SQL in PostgreSQL:

INSERT INTO public.person (email, lastname, "name")
VALUES ('test@example.com', 'Perez', 'Ana');

You should see this in your application console:

Person inserted: test@example.com

Inspect events and triggers with:

select *
from db_change_event
order by id desc;

select trigger_name, event_object_schema, event_object_table
from information_schema.triggers
where event_object_table = 'person';

How it works internally

Tentacolous does not use JPA events. This is intentional: JPA events only work when the change goes through the same application. Tentacolous is designed to detect real database changes, even when they come from outside your application.

In PostgreSQL, Tentacolous creates:

  • A db_change_event table.
  • A db_change_event_notify_change() function.
  • One trigger per table and operation.

The trigger stores entity_name, operation, payload, old_payload, record_key, status, attempts, last_error and processing timestamps.

payload contains the current row for inserts and updates, and the deleted row for deletes. old_payload contains the previous row only for updates. record_key identifies the affected record, using the entity @Id column when available or id otherwise.

When a listener asks for history, Tentacolous queries previous INSERT and UPDATE payloads for the same entity_name and record_key, ordered from oldest to newest.

The poller looks for PENDING events, marks them as PROCESSING, runs the listener and finally marks them as PROCESSED. If an error happens, Tentacolous stores last_error and retries until tentacolous.max-attempts is reached.

Security

  • Do not store secrets in the payload.
  • Use exclude for sensitive columns.
  • Protect db_change_event with proper database permissions.
  • In production, use schema-management=validate or schema-management=none.
  • Keep listeners idempotent because retries may happen.

Production

  • Create the table, function and triggers using controlled migrations.
  • Use tentacolous.schema-management=validate to verify infrastructure.
  • If you manage schema manually, include the old_payload and record_key columns and pass the record key field to the trigger function.
  • Monitor FAILED events.
  • Clean up or archive db_change_event.
  • Avoid slow logic inside listeners.
  • Publish to a queue if the process is heavy.

Running Tentacolous tests

From the library folder:

mvn test

Expected result:

BUILD SUCCESS