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 changes in PostgreSQL, MySQL, MariaDB, SQL Server, Oracle, or SQLite.

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.

Version 0.2.0 adds database-agnostic execution. Tentacolous detects the JDBC product and selects database-specific table, trigger, JSON, pagination, and history behavior while preserving the existing listener and filter API.

Version 0.3.0 adds message-broker sinks. The same event can be forwarded to Kafka or RabbitMQ through a ChangeEventSink, and @TentacolousCapture streams a table without a listener method. See Message brokers.

1. It creates an event table.

2. It creates database-specific trigger infrastructure.

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.

8. Optionally, Tentacolous forwards the same change to a message broker (Kafka, RabbitMQ). See Message brokers.

Requirements

  • Java 17 or higher.
  • Spring Boot.
  • A Spring Boot application with a configured DataSource.
  • PostgreSQL, MySQL, MariaDB, SQL Server, Oracle or SQLite, plus its JDBC driver.

Automatic database infrastructure is available for PostgreSQL, MySQL, MariaDB, SQL Server, Oracle, and SQLite.

Dependency snippets

Gradle

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

Maven

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

Migrating to 0.2.0

Version 0.2.0 remains compatible with existing listeners. PostgreSQL users do not need to replace annotations; other databases need their matching JDBC driver and trigger permissions when automatic schema management is enabled.

  • Use @TentacolousListener only when you prefer the generic annotation with action.
  • Custom filters are optional Spring beans extending TentacolousFilter<T>.
  • The filter generic type must be compatible with the listener entity.
  • If a custom filter is declared together with field, valueType, or value, the custom filter has priority and Tentacolous logs a warning.
  • Declarative filters must define field, valueType, and value together.
  • Each method may declare one listener annotation per operation.

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.

Listeners and operations

Start with the operation-specific annotations. They remain fully supported in 0.2.0 and now also accept custom filters. The generic listener is explained afterward as an optional alternative.

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, start a workflow 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) {
}

Generic alternative: TentacolousListener

@TentacolousListener is the generic alternative. Its action determines the operation and valid method signature.

@TentacolousListener(entity = Person.class, action = ActionListener.INSERT)
public void onPersonInserted(Person person) {
}
@TentacolousListener(entity = Person.class, action = ActionListener.UPDATE)
public void onPersonUpdated(Person newPerson, Person oldPerson, List<Person> history) {
}
@TentacolousListener(entity = Person.class, action = ActionListener.DELETE)
public void onPersonDeleted(Person person, List<Person> history) {
}

The parameter rules are identical to the corresponding operation-specific annotations.

Annotation parameters

The operation-specific annotations and @TentacolousListener(...) share the same listener configuration.

Parameter Required Description
entity Yes Entity class that represents the table and receives the deserialized payload.
action Only for @TentacolousListener ActionListener.INSERT, ActionListener.UPDATE, or ActionListener.DELETE.
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.
filter No Spring bean extending TentacolousFilter<T>. It replaces the declarative filter.
order No Listener execution order. Lower values run first; the default is 0.
exclude No Columns that should not be stored in the event payload.

Listener ordering

Use order when several listeners handle the same entity and operation. Ordering works across specific and generic annotations.

@UponUpdating(entity = Person.class, order = 10)
public void updateProfile(Person person) {
}

@TentacolousListener(
    entity = Person.class,
    action = ActionListener.UPDATE,
    order = 20
)
public void registerAudit(Person person) {
}

If a listener fails, subsequent listeners are not invoked and the event follows the normal retry flow. Listener side effects should be idempotent.

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"

field, valueType, and value must be declared together. If one is missing, Tentacolous fails during listener scanning and prints an example of the valid form.

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"

Custom filters

Programmatic filtering is the main addition in 0.2.0. Define the rule once in a reusable class and reference it from any listener annotation. The filter class must be a Spring bean.

@Component
public class ActivePersonFilter extends TentacolousFilter<Person> {

    @Override
    public boolean accept(TentacolousFilterContext<Person> context) {
        Person person = context.getEntity();
        Person oldPerson = context.getOldEntity();

        return person.isActive()
                && (oldPerson == null
                    || !Objects.equals(person.getStatus(), oldPerson.getStatus()));
    }
}
@TentacolousListener(
    entity = Person.class,
    action = ActionListener.UPDATE,
    filter = ActivePersonFilter.class
)
public void onActivePersonUpdated(Person newPerson, Person oldPerson) {
}

Available on every annotation

@TentacolousListener is optional. Existing annotations remain available and now accept the same custom filter:

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

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

@UponDeleting(entity = Person.class, filter = ActivePersonFilter.class)
public void onPersonDeleted(Person person) {
}
Annotation Operation Custom filter
@UponInserting INSERT Supported
@UponUpdating UPDATE Supported
@UponDeleting DELETE Supported
@TentacolousListener Selected with action Supported

getEntity() contains the current payload. getOldEntity() is available for updates and is null for inserts and deletes. The context also exposes getOperation(), getEventId(), getEntityName(), and getRecordKey().

For updates, getChangedFields() returns the changed JSON field names and hasChanged("status") checks one field. Inserts and deletes return an empty set and false.

When filter is configured together with field, valueType, or value, Tentacolous logs a warning. The custom filter has priority and the declarative filter is not executed. Filter errors follow the normal event retry flow.

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) {
}

Missing valueType

@UponUpdating(
    entity = Person.class,
    field = "name",
    value = "Anthony"
)
public void invalid(Person person) {
}

Use the complete declarative filter instead:

@UponUpdating(
    entity = Person.class,
    field = "name",
    valueType = ValueType.STRING,
    value = "Anthony"
)
public void valid(Person person) {
}

Same operation declared twice

@UponUpdating(entity = Person.class)
@TentacolousListener(entity = Person.class, action = ActionListener.UPDATE)
public void invalid(Person person) {
}

A method can listen to different operations, but not to the same operation twice.

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.

Message brokers

Besides calling listener methods, Tentacolous can forward every change to a message broker through a ChangeEventSink. Enabling a sink turns Tentacolous into a lightweight change-data-capture relay: no Kafka Connect, no WAL/binlog access, just the triggers and event table it already manages.

After the listeners run, the poller hands the event to every registered sink. A sink failure keeps the event PENDING and retries it, so delivery is at-least-once; consumers deduplicate on eventId.

Choosing which tables to forward

A trigger exists only for a table with at least one listener or a capture declaration. To stream a table with no @Upon... method, declare a capture in one of two equivalent ways.

With the annotation (class body may stay empty):

@Configuration
@TentacolousCapture(entity = Person.class)
@TentacolousCapture(entity = Order.class, actions = {ActionListener.INSERT, ActionListener.UPDATE})
public class TentacolousCaptureConfig {
}

With a Capture bean (nothing to instantiate — Spring runs the @Bean method, Tentacolous collects it):

@Configuration
public class TentacolousConfig {

    @Bean
    Capture personCapture() {
        return Capture.of(Person.class);
    }

    @Bean
    Capture approvedOrderCapture() {
        return Capture.of(Order.class)
                .operations(DbOperation.INSERT, DbOperation.UPDATE)
                .where("status", ValueType.STRING, "APPROVED")
                .exclude("internal_notes");
    }
}

Both support actions/.operations, entityName, exclude, a declarative filter (field/valueType/value or .where(...)), a programmatic TentacolousFilter (filter or .filter(...)), and order. A capture filter decides which changes reach the sinks; a listener without an explicit capture still forwards everything. See Message brokers.

Message format

format Body
envelope (default) JSON with eventId, entity, operation, recordKey, observedAt, before, after
raw The row payload exactly as the trigger produced it

For INSERT, before is null. For DELETE, after is null and before holds the removed row. Headers tentacolous-event-id, tentacolous-entity and tentacolous-operation are always present.

Kafka

<dependency>
  <groupId>org.springframework.kafka</groupId>
  <artifactId>spring-kafka</artifactId>
</dependency>
spring:
  kafka:
    bootstrap-servers: localhost:9092

tentacolous:
  kafka:
    enabled: true
    topic-prefix: "cdc."      # topic = cdc.<entityName>
    format: envelope

The record key is the recordKey, so all changes to a row keep their order on the same partition. The producer runs with acks=all and enable.idempotence=true by default; override through spring.kafka.producer.*.

@KafkaListener(topics = "cdc.Person", groupId = "billing")
public void onPersonChange(ConsumerRecord<String, byte[]> record) {
    String json = new String(record.value(), StandardCharsets.UTF_8);
    // parse the envelope, deduplicate on the tentacolous-event-id header
}

RabbitMQ

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
spring:
  rabbitmq:
    host: localhost
    username: guest
    password: guest

tentacolous:
  rabbitmq:
    enabled: true
    exchange: tentacolous
    format: envelope

Messages go to a durable topic exchange with routing key entity.operation (person.update, order.delete). Bind your queues with the pattern you need:

@Bean
Binding billingBinding(Queue billingQueue, TopicExchange tentacolousRabbitExchange) {
    return BindingBuilder.bind(billingQueue).to(tentacolousRabbitExchange).with("person.*");
}

@RabbitListener(queues = "billing.person")
public void onPersonChange(Message message) {
    // message.getBody() is the JSON envelope
}

Custom sink

@Component
public class WebhookSink implements ChangeEventSink {

    @Override
    public String name() {
        return "webhook";
    }

    @Override
    public boolean supports(ChangeEvent event) {
        return "Person".equals(event.getEntityName());
    }

    @Override
    public void publish(ChangeEvent event) throws Exception {
        // forward event.getPayload(); block until acknowledged
    }
}

A lower order() runs before the broker sinks.

Architecture FAQ

Condensed answers to the questions architects raise most often. The full version, with trade-off tables and every failure mode, is on the Architecture FAQ page.

Why use this instead of Debezium?

Debezium reads the transaction log and needs Kafka Connect plus log-level database privileges. Tentacolous is a library: SQL triggers write an outbox row in the business transaction, a Spring poller reads it, and your annotated methods run in the same process. Choose Tentacolous for in-process reactions to external writes at moderate volume with no extra infrastructure; choose Debezium for high-volume, log-fidelity CDC (including TRUNCATE and transaction boundaries) with polyglot consumers. They can also coexist.

What guarantees do I get?

  • No lost events. The event row is written in the same transaction as the business change: present if it commits, gone if it rolls back (transactional outbox).
  • At-least-once delivery to listeners and sinks, retried up to max-attempts.
  • Per-record ordering. Events are processed in id order; the Kafka sink keys records by record key so a partition keeps that order.
  • No exactly-once. Listeners and consumers must be idempotent; deduplicate on eventId.
  • At-most-once dispatch per cycle. Claiming is an atomic UPDATE ... WHERE id = ? AND status = 'PENDING', so two instances cannot both dispatch one event.

What happens if the listener fails?

The exception is caught and logged with the event id, entity, operation and method; last_error and attempts are stored; the row returns to PENDING for retry. After max-attempts it becomes FAILED and is left for you to inspect and replay — the FAILED rows are the dead-letter table. If several listeners share an entity + operation and one throws, dispatch stops and the whole event is retried, so every listener must be idempotent. A crash after claiming but before PROCESSED leaves the row in PROCESSING; the poller does not reap stale rows automatically, so monitor processing_started_at and reset them.

What about Kafka?

Optional and off by default; listeners work with no broker. When enabled, the poller relays each event to every ChangeEventSink after the in-process listeners, in the same retry envelope — a transactional outbox to the broker without Kafka Connect. Records are keyed by record key for per-row ordering; delivery is at-least-once and consumers deduplicate on tentacolous-event-id. Implement ChangeEventSink for any other transport.

Can I capture tables without writing code?

Yes. Declare a @TentacolousCapture annotation or a Capture bean and the change is forwarded to the sinks with no Java listener method, using the same operation selector, exclude and filters as the listener annotations. A capture still needs an entity/table declaration — Tentacolous does not scan undeclared tables.

How does it scale?

Per instance, tune poll-interval (latency) and batch-size (throughput), and above all keep listeners fast: processing is single-threaded and sequential per instance. Several instances can run against the same database safely (atomic claim), but there is no work-stealing, so scale out through a Kafka sink rather than adding pollers. The design targets up to low thousands of events/second per instance; beyond that use log-based CDC. Budget for one extra row write per captured change and schedule archival of PROCESSED rows.

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. Use the built-in Kafka or RabbitMQ sink instead of doing it by hand inside a listener.
  • Keep sink consumers idempotent: delivery is at-least-once, keyed by eventId.

Running Tentacolous tests

From the library folder:

mvn test

Expected result:

BUILD SUCCESS