| Lesson 4 | Enforcing business rules with triggers |
| Objective | What should a Developer consider when enforcing business rules with SQL Server Triggers |
Business rules are the “non-negotiables” of an enterprise: the policies that determine what must be true before a transaction is allowed to complete. Some rules are straightforward (a value can’t be NULL, a salary must be positive), and the relational model gives you declarative tools—keys, foreign keys, CHECK constraints—to enforce them efficiently and predictably.
Other rules are more contextual. They might require comparing the old state to the new state, checking a related table, recording approval metadata, or initiating an audit trail. When a rule can’t be expressed cleanly as a declarative constraint, SQL Server triggers can be an appropriate enforcement mechanism—if you design them with care.
This lesson focuses on what a developer should consider before enforcing business rules with SQL Server triggers, and how to implement trigger-based rules that remain correct, testable, and maintainable over time.
Triggers are powerful, but they’re not the default choice. Start by selecting the simplest database feature that correctly enforces the rule:
A trigger becomes reasonable when the rule:
A helpful mindset from relational theory: prefer declarative constraints because they are a direct expression of the relation’s allowed states. Use triggers as an enforcement mechanism for rules that are procedural by nature, or that require history/approval context beyond the relation’s basic structure.
SQL Server DML triggers fire for three statement types: INSERT, UPDATE, and DELETE. A key detail that trips up many developers is that a trigger fires per statement, not per row. That means a single UPDATE statement can affect 1 row or 10,000 rows, and your trigger must behave correctly in both cases.
Inside a trigger, SQL Server exposes two special “pseudo-tables” that represent the set of affected rows:
inserted – the new version of rows created or modified.deleted – the old version of rows deleted or modified.
These sets are the foundation of correct trigger design. Any rule that compares old vs new values should
be expressed as a set-based comparison between inserted and deleted.
SQL Server provides two execution models:
FOR): fire after the DML statement has executed and after constraint checks succeed.Most business rule enforcement on base tables uses AFTER triggers because they run after the engine has applied the change, and because constraints have already validated the data shape. An AFTER trigger can still reject the operation by throwing an error and rolling back the transaction.
INSTEAD OF triggers are best reserved for controlled write access to views, or for specialized routing logic. Because they intercept the DML, they also demand more discipline: you must explicitly implement the modification, and you must carefully reason about side-effects and concurrency.
Triggers run inside the same transaction as the statement that fired them. That has major implications:
A simplified transaction flow for a typical INSERT/UPDATE looks like this:
Practical consequence: if a constraint is violated, an AFTER trigger does not run at all. If you need behavior that executes even when the DML is rejected, you must handle it elsewhere (for example, in application validation or in a stored procedure layer), not in an AFTER trigger.
A trigger runs every time the table is modified, which means its cost is paid on every write path. That cost is easy to underestimate because it is not visible at the application layer. Developers should therefore keep triggers:
Use SET NOCOUNT ON; at the top of triggers to avoid extra rowcount messages, which can reduce noise for
clients and improve some workloads.
CREATE TRIGGER dbo.trg_Example
ON dbo.MyTable
AFTER INSERT, UPDATE, DELETE
AS
BEGIN
SET NOCOUNT ON;
-- business rule enforcement
END;
Triggers often age poorly when the rule is not clearly documented. A future developer will see “magic behavior” and may not realize the trigger exists, especially if the application “usually works” until it doesn’t. Maintainability practices that pay off:
trg_Table_Operation_RuleName).SQL Server can have multiple AFTER triggers on the same table. Relying on their execution order is a common design mistake. If two triggers interact, the system becomes brittle and difficult to reason about.
If you have multiple trigger-based rules, treat each trigger as independent and avoid implicit dependencies. If dependency is unavoidable, consider consolidating enforcement into a single trigger or moving enforcement into a stored procedure boundary for that write path.
A trigger can modify data, which can cause other triggers to fire. In complex schemas, this can produce nested trigger execution and even recursion. This is one of the main reasons triggers are associated with “surprising” behavior in legacy systems.
As a developer, you should:
If nested triggers are not desired, they can be disabled at the server level. Do this only with full awareness of its impact on existing systems.
EXEC sp_configure 'nested triggers', 0;
RECONFIGURE;
Business rules are often tied to accountability: approvals, thresholds, escalation paths, and auditability. When triggers enforce a rule, they should also support traceability:
SUSER_SNAME()) when logging rule violations or approvals,If your system uses row-level security (RLS), encryption, or strict permission boundaries, you also need to confirm that trigger behavior aligns with your security model and does not unintentionally bypass intent.
The following trigger demonstrates several good trigger practices:
inserted and deleted,
-- Base table
CREATE TABLE dbo.Employees (
EmployeeID INT IDENTITY(1,1) PRIMARY KEY,
FirstName NVARCHAR(50) NOT NULL,
LastName NVARCHAR(50) NOT NULL,
Salary DECIMAL(10,2) NOT NULL CHECK (Salary > 0),
LastUpdated DATETIME2(0) NOT NULL CONSTRAINT DF_Employees_LastUpdated DEFAULT SYSUTCDATETIME()
);
-- Audit log for violations
CREATE TABLE dbo.SalaryAuditLog (
AuditID INT IDENTITY(1,1) PRIMARY KEY,
EmployeeID INT NOT NULL,
OldSalary DECIMAL(10,2) NOT NULL,
NewSalary DECIMAL(10,2) NOT NULL,
AttemptedBy NVARCHAR(128) NOT NULL,
AttemptDate DATETIME2(0) NOT NULL CONSTRAINT DF_SalaryAudit_AttemptDate DEFAULT SYSUTCDATETIME(),
ErrorMessage NVARCHAR(500) NOT NULL
);
GO
CREATE TRIGGER dbo.trg_Employees_SalaryIncreaseLimit
ON dbo.Employees
AFTER UPDATE
AS
BEGIN
SET NOCOUNT ON;
IF UPDATE(Salary)
BEGIN
-- Any row whose salary increased by more than 20% violates the rule
IF EXISTS (
SELECT 1
FROM inserted i
JOIN deleted d ON d.EmployeeID = i.EmployeeID
WHERE i.Salary > d.Salary * 1.20
)
BEGIN
INSERT INTO dbo.SalaryAuditLog (EmployeeID, OldSalary, NewSalary, AttemptedBy, ErrorMessage)
SELECT
i.EmployeeID,
d.Salary,
i.Salary,
SUSER_SNAME(),
'Salary increase exceeds 20% limit.'
FROM inserted i
JOIN deleted d ON d.EmployeeID = i.EmployeeID
WHERE i.Salary > d.Salary * 1.20;
THROW 50001, 'Update failed: salary increase cannot exceed 20%.', 1;
END;
-- Valid salary updates can record metadata (optional)
UPDATE e
SET LastUpdated = SYSUTCDATETIME()
FROM dbo.Employees e
JOIN inserted i ON i.EmployeeID = e.EmployeeID;
END
END;
GO
When testing triggers, always test both single-row and multi-row operations, and test rollback behavior. You can also wrap tests in an explicit transaction and roll back at the end to keep your environment clean.
BEGIN TRAN;
INSERT INTO dbo.Employees (FirstName, LastName, Salary)
VALUES ('John','Doe',50000.00), ('Jane','Smith',60000.00);
-- Valid update (10% increase)
UPDATE dbo.Employees
SET Salary = Salary * 1.10
WHERE LastName = 'Doe';
-- Invalid update (30% increase)
UPDATE dbo.Employees
SET Salary = Salary * 1.30
WHERE LastName = 'Doe';
-- If the second update fails, check the audit log in a separate session or after handling the error
SELECT * FROM dbo.SalaryAuditLog ORDER BY AuditID DESC;
ROLLBACK;
This approach allows you to confirm that the trigger enforces the rule, logs the attempted violation, and prevents invalid data from being committed.
Triggers are best viewed as a specialized enforcement tool for business rules that are procedural, cross-row, or audit-driven. They provide a powerful guarantee: regardless of the caller, invalid changes can be rejected before commit. But that guarantee comes with responsibility—trigger logic must be multi-row safe, set-based, well-documented, and designed with transaction cost in mind.
When you start from relational fundamentals—use declarative constraints for structural rules and triggers only when necessary—you end up with enforcement logic that is easier to reason about, faster under load, and far less surprising to maintain over time.