PL/SQL anti-pattern
PL/SQL trigger side effects
Row-level triggers that change values, write to other tables and enforce rules on every DML statement, so business behavior fires no matter which application, script or batch job touched the row.
- FAMILY
- PL/SQL
- CUTOVER RISK
- Critical
- Changes runtime behavior in ways a line-by-line reading misses. Resolve before any cutover plan.
- MODERN MAPPING
- Explicit domain operation with events via an outbox
- GENERATED ARTIFACT
- asyncapi.yaml
Before and after
legacy source → modern mapping
Legacy: exhibits the pattern
1CREATE OR REPLACE TRIGGER trg_loan_bu2BEFORE UPDATE OF status, balance ON loans3FOR EACH ROW4BEGIN5 IF :NEW.status = 'DEFAULT'6 AND :OLD.status <> 'DEFAULT' THEN7 :NEW.default_date := SYSDATE;8 :NEW.rate := :OLD.rate + 0.0200; -- penalty rate9 INSERT INTO collections_queue (loan_id, queued_at)10 VALUES (:NEW.loan_id, SYSDATE);11 END IF;12 IF :NEW.balance < 0 THEN13 RAISE_APPLICATION_ERROR(-20010, 'Negative balance');14 END IF;15END;
Modern: Explicit domain operation with events via an outbox
1public void markDefaulted(Clock clock) {2 if (status == LoanStatus.DEFAULT) {3 return; // :OLD.status check4 }5 status = LoanStatus.DEFAULT;6 defaultDate = LocalDate.now(clock);7 rate = rate.add(PENALTY_RATE); // +0.0200, R-LN-078 events.add(new LoanDefaulted(id, defaultDate));9}1011public void applyPayment(BigDecimal amount) {12 BigDecimal next = balance.subtract(amount);13 if (next.signum() < 0) {14 throw new LoanRuleException("-20010");15 }16 balance = next;17}
Why it is dangerous in modernization
A BEFORE UPDATE FOR EACH ROW trigger can rewrite :NEW values, insert into queue tables and raise application errors, and none of it is visible to the application that issued the UPDATE. Over decades, triggers become the place where teams put rules they wanted enforced everywhere: penalty rates on default, audit rows, derived balances, queue entries for downstream jobs. When the data moves to a new database or the logic moves into services, triggers are easy to forget because no application code calls them. The new service updates a loan's status and nothing else happens: no penalty rate, no collections queue entry, no default date. Triggers also have their own edge cases: they do not fire on direct path loads, they can hit ORA-04091 (mutating table) when they query their own table, and comparisons with NULL quietly skip their logic.
How PYD ENTERPRISES detects it
PYD ENTERPRISES parses trigger DDL with its timing, events, column lists (UPDATE OF), WHEN clause and body, and links each trigger to its table in the column-level lineage graph. Every DML statement found in applications, packages and scripts is then matched to the triggers it fires, so each write path shows its hidden side effects. Assignments to :NEW are extracted as derivation rules, inserts into other tables as events, and RAISE_APPLICATION_ERROR calls as constraints with their error numbers. Compound triggers and package variables used to work around mutating-table errors are recognised and reported as cross-row state.
Maps to: Explicit domain operation with events via an outbox
Each trigger's derivations become part of the domain operation that changes the state, such as markDefaulted(), so the rule runs because the business operation runs. Inserts into queue tables become domain events published through a transactional outbox, described in AsyncAPI. Constraints become validations in the service and, where they protect data integrity, CHECK constraints in the new schema.
Generated specification fragment
1asyncapi: '2.6.0'2info:3 title: Loan events4 version: '1.0.0'5channels:6 loan.defaulted:7 subscribe:8 operationId: onLoanDefaulted9 description: Replaces INSERT INTO collections_queue10 in trg_loan_bu (trg_loan_bu.sql:9-10)11 message:12 payload:13 type: object14 required: [loanId, defaultDate]15 properties:16 loanId: { type: string }17 defaultDate: { type: string, format: date }
Every generated element links back to the source file and line range shown in the legacy pane. Review and sign off before building against it.
The NULL case nobody tested
:OLD.status <> 'DEFAULT' is not true when :OLD.status is NULL; in SQL the comparison yields unknown and the IF branch is skipped. A loan inserted without a status and later moved straight to DEFAULT gets no penalty rate and no collections entry. The engine flags comparisons on nullable columns inside trigger conditions, using the column's nullability from the table DDL, and generates a fixture with the NULL case so the team can decide whether the new markDefaulted() should reproduce or correct it. In the Java shown above, the NULL case is corrected, deliberately, and that decision is recorded in the specification.
Writes that bypass triggers
- SQL*Loader direct path loads do not fire insert triggers.
- Data Pump imports and partition exchanges move rows without row triggers.
- Triggers can be disabled during maintenance windows and re-enabled afterwards.
If legacy batch loads use any of these, the trigger's rule never applied to those rows, and the historical data is inconsistent with the rule. PYD ENTERPRISES lists loaders and scripts found in the ingest that write to triggered tables by such paths, which helps the data migration team reconcile history before cutover.
Ordering with other triggers
A table can have several triggers for the same timing and event. Without a FOLLOWS clause, Oracle does not guarantee their firing order. When two BEFORE ROW triggers both assign :NEW.rate, the result depends on an order the database does not promise. The engine reports overlapping assignments across triggers on the same table as a finding of its own, because no deterministic specification can be extracted until someone decides which one wins.
Related patterns
Usually found in the same codebase.
- PL/SQLHigh
PL/SQL row-by-row cursor
Maps to Set-based query plus explicit rule table
- T-SQLHigh
T-SQL dynamic SQL rules
Maps to Parameterised query with criteria as a reviewed decision table
- Java EEHigh
EJB 2.x entity beans
Maps to JPA entity, repository and transactional service
- SpringHigh
Spring XML and AOP hidden logic
Maps to Explicit composition with declared cross-cutting concerns
Find every PL/SQL trigger side effects in your codebase.
Run PYD ENTERPRISES in your private VPC and get each occurrence with file, line range, modern mapping and equivalence fixtures, ready for your architects to review.