Choose the right boundary among constraints, generated data, routines, triggers, and application services, then test database behavior with repeatable SQL and application contract checks without proprietary tooling.

When to Use Database Logic vs Application Services—and How to Test Both

Choose the right boundary among constraints, generated data, routines, triggers, and application services, then test database behavior with repeatable SQL and application contract checks without proprietary tooling.

Intermediate → Advanced180–240 minutesServer-side programming and deployment safetyPostgreSQL 18.6 baselineCore SQL + built-in PL/pgSQL; no third-party extension requiredServiceHub disposable objects: app.ch18_*Lesson 4 uses a disposable admin database and superuser-equivalent local lab accountLocal/free tooling; psql recommendedLast reviewed: August 18, 2026

Learning outcomes

Server-side programming is most effective when each rule is placed at the layer that can enforce it clearly. ServiceHub should not turn PostgreSQL into an opaque application server, and it should not move transaction-critical integrity out to every caller. This lesson builds a decision model and repeatable tests for one database routine so correctness can survive refactoring between database and application layers.

01

Compare constraints, generated columns, functions/procedures, triggers, and application services by atomicity, portability, observability, ownership, and scaling.

02

Write pgTAP-free SQL tests that fail deterministically through SQLSTATE/RAISE when a routine violates its contract.

03

Run tests inside transactions and roll them back so the suite is repeatable.

04

Define application contract tests around result shape, SQLSTATE, permissions, and transaction semantics rather than implementation text.

05

Use catalogs and dependency queries to include database routines in deployment/review workflows.

1. Choose the lowest layer that can express the invariant clearly

Mechanism Best fit Main strength Main tradeoff
CHECK / FK / UNIQUE / EXCLUDE / NOT NULL Declarative data invariants Every writer must obey; optimizer/tool visibility Limited to declarative semantics
Generated column/default Deterministic row-owned derivation/default Central, automatically maintained Not a workflow engine
SQL/PLpgSQL function Reusable query/transaction logic returning a value Atomic with caller SQL; narrow API DB-specific release/observability burden
Procedure CALL-able administrative/batch routine Can own transaction boundaries in allowed contexts Not composable as query expression
Trigger Must-run local invariant/audit for every writer Implicit enforcement Hidden execution/order/recursion
Application service External APIs, network calls, long workflows, cross-service policy Rich observability and deployment ownership Cannot alone guarantee DB integrity against every writer

A useful heuristic is “constraint first, explicit routine second, trigger only when implicit every-writer behavior is necessary, application service for orchestration.” This is a starting point, not a universal architecture law.

2. A routine contract should be testable without knowing its implementation

Use app.ch18_cancel_work_order from Lesson 2. Its observable contract is: a valid queued/assigned work order becomes cancelled, one audit row is written, short reasons fail with SQLSTATE P1802, completed orders fail with P1803, and missing IDs raise PostgreSQL's no-data-found path unless intentionally translated.

sql · positive contract test inside a rolled-back transaction
BEGIN;INSERT INTO app.ch18_work_order(work_order_id,status,labor_minutes,hourly_rate)VALUES (18901,'assigned',30,100.00);DO $$DECLARE  v_result app.ch18_work_order;  v_audit_count integer;BEGIN  v_result := app.ch18_cancel_work_order(18901,'integration test cancellation');  IF v_result.status <> 'cancelled' THEN    RAISE EXCEPTION 'TEST FAIL: expected cancelled, got %', v_result.status;  END IF;  SELECT count(*) INTO v_audit_count  FROM app.ch18_work_order_change  WHERE work_order_id = 18901    AND new_status = 'cancelled';  IF v_audit_count <> 1 THEN    RAISE EXCEPTION 'TEST FAIL: expected one audit row, got %', v_audit_count;  END IF;  RAISE NOTICE 'TEST PASS: successful cancellation contract';END$$;ROLLBACK;

The transaction rollback keeps the test idempotent. ON_ERROR_STOP in psql (shown later) turns the first failed assertion into a failing test command instead of continuing silently.

3. Test failure contracts by SQLSTATE, not message text

sql · negative SQLSTATE test
DO $$BEGIN  BEGIN    PERFORM app.ch18_cancel_work_order(18002,'bad');    RAISE EXCEPTION 'TEST FAIL: expected P1802';  EXCEPTION    WHEN SQLSTATE 'P1802' THEN      RAISE NOTICE 'TEST PASS: short reason rejected with P1802';  END;END$$;

Messages can be reworded or localized. SQLSTATE is the durable machine-facing contract. If you invent application-specific codes, document them and avoid category-style codes ending in three zeroes.

4. Privilege tests are part of behavior

sql · catalog privilege assertions for the definer API
SELECT  has_function_privilege(    'servicehub_app',    'app.ch18_app_cancel_work_order(bigint,text)',    'EXECUTE'  ) AS app_can_execute,  EXISTS (    SELECT 1    FROM pg_proc AS p    CROSS JOIN LATERAL aclexplode(      coalesce(p.proacl, acldefault('f', p.proowner))    ) AS x    WHERE p.oid = 'app.ch18_app_cancel_work_order(bigint,text)'::regprocedure      AND x.grantee = 0      AND x.privilege_type = 'EXECUTE'  ) AS public_can_execute;DO $$DECLARE  v_public_execute boolean;BEGIN  IF NOT has_function_privilege(      'servicehub_app',      'app.ch18_app_cancel_work_order(bigint,text)',      'EXECUTE') THEN    RAISE EXCEPTION 'TEST FAIL: servicehub_app lacks EXECUTE';  END IF;  SELECT EXISTS (    SELECT 1    FROM pg_proc AS p    CROSS JOIN LATERAL aclexplode(      coalesce(p.proacl, acldefault('f', p.proowner))    ) AS x    WHERE p.oid = 'app.ch18_app_cancel_work_order(bigint,text)'::regprocedure      AND x.grantee = 0      AND x.privilege_type = 'EXECUTE'  ) INTO v_public_execute;  IF v_public_execute THEN    RAISE EXCEPTION 'TEST FAIL: PUBLIC unexpectedly has EXECUTE';  END IF;END$$;

Security regressions are interface regressions. Test owner, ACL, prosecdef, and routine-level search_path settings alongside result rows.

5. Catalog tests catch metadata regressions

sql · assert volatility and search_path properties
SELECT p.oid::regprocedure,       p.provolatile,       p.proparallel,       p.prosecdef,       p.proconfigFROM pg_proc AS pWHERE p.oid IN (  'app.ch18_labor_cost(integer,numeric)'::regprocedure,  'app.ch18_app_cancel_work_order(bigint,text)'::regprocedure);DO $$DECLARE  v_volatility "char";BEGIN  SELECT provolatile INTO v_volatility  FROM pg_proc  WHERE oid = 'app.ch18_labor_cost(integer,numeric)'::regprocedure;  IF v_volatility <> 'i' THEN    RAISE EXCEPTION 'TEST FAIL: labor cost must remain IMMUTABLE';  END IF;END$$;

A code review that checks only function text can miss an accidental ALTER FUNCTION ... VOLATILE, ownership change, or PUBLIC EXECUTE grant. Catalog assertions bring deployment metadata into the test surface.

6. Run SQL tests as a repeatable command

Put the SQL assertions in a file such as tests/ch18_routine_contract.sql. The lesson does not require pgTAP or any proprietary test framework.

shell · psql test-runner contract
psql   --dbname="service=servicehub-lab-admin"   --set=ON_ERROR_STOP=1   --file=tests/ch18_routine_contract.sql

The process exit code becomes the CI signal: zero means all SQL assertions completed; nonzero means at least one SQL error/RAISE stopped the script. Capture server/client versions in CI logs so behavior changes are attributable.

7. Application contract test: test the database boundary, not PL/pgSQL internals

An application test should invoke the same public routine through the real driver identity and assert result/error behavior. The example below uses Psycopg 3, an open-source PostgreSQL driver, as an optional application-layer example; the mandatory chapter remains runnable with core PostgreSQL/psql alone.

python · optional Psycopg 3 contract example
import psycopgDSN = "service=servicehub-lab-app"with psycopg.connect(DSN) as conn:    with conn.cursor() as cur:        cur.execute("BEGIN")        cur.execute(            "INSERT INTO app.ch18_work_order "            "(work_order_id,status,labor_minutes,hourly_rate) "            "VALUES (%s,%s,%s,%s)",            (18902, "assigned", 25, 95.00),        )        cur.execute(            "SELECT app.ch18_app_cancel_work_order(%s,%s)",            (18902, "application contract test"),        )        assert cur.fetchone()[0] == 18902        conn.rollback()try:    with psycopg.connect(DSN) as conn:        with conn.cursor() as cur:            cur.execute(                "SELECT app.ch18_app_cancel_work_order(%s,%s)",                (18002, "bad"),            )except psycopg.Error as exc:    assert exc.sqlstate == "P1802"

Values remain bound parameters, the connection uses the application role/service, and the assertion checks SQLSTATE rather than text. Do not expose table-owner credentials to make application tests easier.

8. Wrong architecture test: mock away the database invariant

Deliberately incomplete approach

A unit test that mocks “cancel_work_order() returns success” can prove application branching but cannot prove database permissions, transaction atomicity, trigger side effects, SQLSTATE, or constraint behavior. Keep fast unit tests, but add at least one real PostgreSQL contract test for every database API that owns correctness.

Conversely, a database-only test cannot prove HTTP retries, idempotency keys, observability, connection-pool behavior, or service authorization. Test each boundary where it actually lives.

9. Deployment and observability checklist

sql · routine dependency and ownership inventory
SELECT p.oid::regprocedure AS routine,       pg_get_userbyid(p.proowner) AS owner,       p.prokind,       p.prosecdef,       p.proacl,       p.proconfigFROM pg_proc AS pJOIN pg_namespace AS n ON n.oid = p.pronamespaceWHERE n.nspname = 'app'  AND p.proname LIKE 'ch18_%'ORDER BY routine::text;SELECT pg_describe_object(d.classid, d.objid, d.objsubid) AS dependent_object,       d.deptype,       d.refobjid::regprocedure AS referenced_routineFROM pg_depend AS dWHERE d.refclassid = 'pg_proc'::regclass  AND d.refobjid IN (    SELECT p.oid FROM pg_proc AS p    JOIN pg_namespace AS n ON n.oid=p.pronamespace    WHERE n.nspname='app' AND p.proname LIKE 'ch18_%'  )ORDER BY referenced_routine::text, dependent_object;

Deployment review should include function signatures, ownership/ACLs, volatility/parallel labels, routine-level settings, dependent triggers/views, migration order, rollback plan, and application compatibility. CREATE OR REPLACE FUNCTION preserves ownership/permissions, but changing argument or return types often requires drop/recreate and dependency handling.

10. Cleanup and bridge to extensibility

sql · cleanup Chapter 18 disposable ServiceHub objects
DROP VIEW IF EXISTS app.ch18_work_order_queue CASCADE;DROP TABLE IF EXISTS app.ch18_trigger_audit CASCADE;DROP TABLE IF EXISTS app.ch18_trigger_order CASCADE;DROP TABLE IF EXISTS app.ch18_work_order_change CASCADE;DROP TABLE IF EXISTS app.ch18_work_order CASCADE;DROP TABLE IF EXISTS app.ch18_rate_config CASCADE;DROP FUNCTION IF EXISTS app.ch18_app_cancel_work_order(bigint,text);DROP FUNCTION IF EXISTS app.ch18_cancel_with_diagnostics(bigint,text);DROP FUNCTION IF EXISTS app.ch18_cancel_work_order(bigint,text);DROP FUNCTION IF EXISTS app.ch18_count_orders_by(text,text);DROP FUNCTION IF EXISTS app.ch18_count_orders_unsafe(text);DROP FUNCTION IF EXISTS app.ch18_status_summary();DROP FUNCTION IF EXISTS app.ch18_rate(text);DROP FUNCTION IF EXISTS app.ch18_rate_bad(text);DROP FUNCTION IF EXISTS app.ch18_orders_by_status(text);DROP FUNCTION IF EXISTS app.ch18_labor_cost(integer,numeric);DROP FUNCTION IF EXISTS app.ch18_close_work_order(bigint);DROP FUNCTION IF EXISTS app.ch18_touch_order();DROP FUNCTION IF EXISTS app.ch18_audit_order_update_statement();DROP FUNCTION IF EXISTS app.ch18_audit_status_row();DROP FUNCTION IF EXISTS app.ch18_queue_insert();DROP FUNCTION IF EXISTS app.ch18_completed_requires_closed_at();DROP FUNCTION IF EXISTS app.ch18_recursive_touch_bad();DROP PROCEDURE IF EXISTS app.ch18_complete_zero_labor_queue();

Chapter 19 expands the same discipline beyond built-in SQL/PLpgSQL routines into extensions, Foreign Data Wrappers, procedural languages, custom operators/types, and extension lifecycle/security. The key principle carries forward: code that enters the PostgreSQL server process or optimizer becomes part of the database trust and upgrade surface.

Check your understanding

  1. Which database rule should generally be preferred over a trigger when a declarative constraint can express it?
  2. Why are rolled-back SQL tests useful for routine contracts?
  3. What should an application contract test assert besides a returned value?
  4. Why test routine ACL/volatility/search_path metadata in catalogs?
  5. What can a mocked application unit test not prove about a database routine?
Review the answers

Prefer a declarative constraint when possible. Transaction-wrapped tests leave the shared lab unchanged and repeat cleanly. Application tests should assert SQLSTATE, permissions/identity, result shape, transaction behavior, and relevant side effects. Catalog metadata can change correctness/security without changing body text. A mock cannot prove real PostgreSQL privileges, atomicity, constraints, triggers, or error codes.

Authoritative references

Routines, trigger timing, privileges, and planner promises are version-sensitive. These PostgreSQL 18 primary sources define the behavior used in this lesson.

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only assets compatible with the Ethereum/ERC-20 network. Do not send TRC-20/TRON assets.