mirror of
https://github.com/magnus919/agent-skills.git
synced 2026-09-12 20:16:29 +03:00
Engineering: backend-engineering, frontend-engineering, data-engineering, ml-engineering, platform-engineering, qa-methodology Executive: go-to-market, legal-strategy, operational-design, org-design, product-strategy ml-engineering: added missing training-infrastructure.md reference qa-methodology: added test-data-management, performance-testing, security-testing references All frontmatter converted to agent-skills convention. Source: https://github.com/magnus919/hermes-profiles
1085 lines
42 KiB
Markdown
1085 lines
42 KiB
Markdown
# Backend Engineering Methodology Reference
|
|
|
|
> Database access patterns & service-level testing — a comprehensive reference for backend engineering teams.
|
|
> Compiled: 2026-06-05
|
|
|
|
---
|
|
|
|
## Table of Contents
|
|
|
|
1. [Connection Pooling Configuration & Sizing](#1-connection-pooling-configuration--sizing)
|
|
2. [Query Optimization — Index Usage, Query Plans, EXPLAIN](#2-query-optimization--index-usage-query-plans-explain)
|
|
3. [N+1 Detection & Mitigation](#3-n1-detection--mitigation)
|
|
4. [Pagination Strategies — Cursor vs Offset vs Keyset](#4-pagination-strategies--cursor-vs-offset-vs-keyset)
|
|
5. [Transaction Boundary Design](#5-transaction-boundary-design)
|
|
6. [Read/Write Splitting](#6-readwrite-splitting)
|
|
7. [Replication Lag Handling](#7-replication-lag-handling)
|
|
8. [Service-Level Testing Overview](#8-service-level-testing-overview)
|
|
9. [Unit Testing Business Logic](#9-unit-testing-business-logic)
|
|
10. [Integration Testing — API Contracts, Testcontainers, WireMock](#10-integration-testing--api-contracts-testcontainers-wiremock)
|
|
11. [Contract Testing — Pact](#11-contract-testing--pact)
|
|
12. [Test Fixtures](#12-test-fixtures)
|
|
13. [CI Integration](#13-ci-integration)
|
|
|
|
---
|
|
|
|
## 1. Connection Pooling Configuration & Sizing
|
|
|
|
### The Problem
|
|
|
|
Creating a new TCP connection per request does not scale. At 10K+ RPS, the database is overwhelmed. PostgreSQL defaults to 100 simultaneous connections; exceeding that produces "sorry, too many clients already." Each new connection setup adds 20-50 ms of latency.
|
|
|
|
### The Solution
|
|
|
|
Connection pooling pre-establishes a fixed set of connections at application startup. Threads borrow a connection, execute queries, and return it to the pool.
|
|
|
|
```
|
|
┌──────────────┐ borrow ┌──────────────────┐
|
|
│ App Thread │ ─────────────→ │ Connection │
|
|
│ (request) │ │ Pool │
|
|
│ │ ←───────────── │ (HikariCP/ │
|
|
│ │ return │ pgBouncer) │
|
|
└──────────────┘ └────────┬─────────┘
|
|
│
|
|
┌─────────▼─────────┐
|
|
│ Database Server │
|
|
│ (PostgreSQL/MySQL)│
|
|
└───────────────────┘
|
|
```
|
|
|
|
### Pool Sizing Formula
|
|
|
|
The most commonly cited rule of thumb: **pool size = 2x (number of CPU cores)**.
|
|
|
|
However, the correct approach is empirical:
|
|
|
|
1. **Start small** — 20-30 connections for most services.
|
|
2. **Run load tests** with real traffic patterns. Monitor DB CPU, memory, connection wait times, and query latency.
|
|
3. **Add a 15-20% buffer** above measured peak usage.
|
|
4. **Consider multiple pools** for distinct workload patterns (e.g., small pool for admin queries, larger for user-facing traffic).
|
|
|
|
### Key Configuration Parameters
|
|
|
|
| Parameter | Description | Common Default |
|
|
|-----------|-------------|---------------|
|
|
| `maximumPoolSize` | Max connections in the pool | 10-30 |
|
|
| `minimumIdle` | Min idle connections to maintain | same as maxPoolSize |
|
|
| `connectionTimeout` | Max wait time for a connection (ms) | 30000 |
|
|
| `idleTimeout` | Max time a connection stays idle (ms) | 600000 (10 min) |
|
|
| `maxLifetime` | Max lifetime of a connection in pool (ms) | 1800000 (30 min) |
|
|
|
|
### Recommended Libraries
|
|
|
|
| Language | Library | Notes |
|
|
|----------|---------|-------|
|
|
| Java/Kotlin | **HikariCP** | Industry standard — fastest, lightest |
|
|
| Python | **psycopg2.pool / SQLAlchemy pool** | Built-in; tune pool_size and max_overflow |
|
|
| Node.js | **pg-pool** | Default pool for node-postgres |
|
|
| Go | **pgxpool** (/jackc/pgx) | High-performance Postgres driver |
|
|
| Ruby | **connection_pool** | Used by ActiveRecord internally |
|
|
| Rust | **deadpool-postgres** | Async pool for tokio-postgres |
|
|
| .NET | **Npgsql pooling (built-in)** | Connection pooling enabled by default |
|
|
|
|
### Proxy-Based Pooling (pgBouncer / PgCat)
|
|
|
|
For microservices or serverless, use a database proxy instead of app-level pooling:
|
|
|
|
```ini
|
|
[databases]
|
|
mydb = host=localhost port=5432 dbname=mydb
|
|
|
|
[pgbouncer]
|
|
listen_addr = 127.0.0.1
|
|
listen_port = 6432
|
|
pool_mode = transaction # transaction-level pooling
|
|
max_client_conn = 100
|
|
default_pool_size = 20
|
|
```
|
|
|
|
- **Transaction pooling** — connections are returned to pool after each transaction (most common).
|
|
- **Session pooling** — connection held for entire session (useful for prepared statements).
|
|
- **Statement pooling** — connection returned after each statement (rarest).
|
|
|
|
### Serverless Considerations
|
|
|
|
Serverless functions are short-lived and cannot maintain persistent pools. Use proxy-based solutions:
|
|
|
|
- **AWS RDS Proxy** (managed, IAM auth)
|
|
- **Cloudflare Hyperdrive**
|
|
- **Supabase Supavisor**
|
|
- **PgCat** (open-source proxy)
|
|
|
|
---
|
|
|
|
## 2. Query Optimization — Index Usage, Query Plans, EXPLAIN
|
|
|
|
### Index Types (PostgreSQL-focused)
|
|
|
|
| Index Type | Best For | Considerations |
|
|
|------------|----------|---------------|
|
|
| **B-Tree** (default) | Equality & range queries, ORDER BY, foreign keys | General-purpose; works for most cases |
|
|
| **Hash** | Equality lookups only | Single-column; not WAL-logged in older versions |
|
|
| **GIN** (Generalized Inverted Index) | Full-text search, arrays, JSONB containment | Larger than B-tree; slower to build |
|
|
| **GiST** (Generalized Search Tree) | Geometric data, full-text search (ranking) | Lossy; supports nearest-neighbor |
|
|
| **BRIN** (Block Range INdex) | Very large tables with naturally ordered data (time-series, logs) | Extremely compact; only good for correlated data |
|
|
| **SP-GiST** | Space-partitioned data (maps, network trees) | Niche; for clustered data |
|
|
| **Covering Index** (`INCLUDE` columns) | Index-only scans | Adds payload columns without affecting key sort order |
|
|
|
|
### Composite Index Guidelines
|
|
|
|
- **Order matters**: column(s) for equality first, then range/ORDER BY columns.
|
|
- **Leftmost prefix rule**: a query must use the leftmost columns in the index to benefit from it.
|
|
- Example: `CREATE INDEX idx_users_org_status ON users (organization_id, status, created_at);`
|
|
- Helps `WHERE org_id = ? AND status = ? ORDER BY created_at`
|
|
- Does NOT help `WHERE status = ?` alone.
|
|
|
|
### EXPLAIN Fundamentals
|
|
|
|
```sql
|
|
EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) SELECT * FROM orders WHERE user_id = 42;
|
|
```
|
|
|
|
**Key plan node types:**
|
|
|
|
| Node | Meaning |
|
|
|------|---------|
|
|
| **Seq Scan** | Full table scan — expensive on large tables |
|
|
| **Index Scan** | Index lookup + heap fetch |
|
|
| **Index Only Scan** | All needed columns in the index itself (fastest) |
|
|
| **Bitmap Heap Scan** | Multiple index matches combined into a bitmap |
|
|
| **Nested Loop** | For each row in outer, scan inner (good for small joins) |
|
|
| **Hash Join** | Build hash table on one side, probe with other |
|
|
| **Merge Join** | Sort both sides, merge (good for large sorted sets) |
|
|
|
|
### What to Look For in a Query Plan
|
|
|
|
1. **Sequential scans on large tables** — missing index.
|
|
2. **High `rows` vs `actual rows` discrepancy** — planner has stale statistics; run `ANALYZE`.
|
|
3. **`Sort` nodes with large memory** — consider pre-sorted index or increased `work_mem`.
|
|
4. **`Nested Loop` joining large row sets** — might need a different join strategy.
|
|
5. **`Bitmap Heap Scan` with many row versions** — vacuum might be needed.
|
|
6. **`Filter` after index scan** — index is missing a column used in WHERE.
|
|
|
|
### Index Maintenance
|
|
|
|
```sql
|
|
-- Check index usage
|
|
SELECT schemaname, tablename, indexname, idx_scan, idx_tup_read, idx_tup_fetch
|
|
FROM pg_stat_user_indexes
|
|
WHERE idx_scan = 0; -- unused indexes (candidates for removal)
|
|
|
|
-- Rebuild bloated indexes
|
|
REINDEX INDEX CONCURRENTLY idx_name; -- non-blocking in PG 12+
|
|
```
|
|
|
|
### Common Anti-Patterns
|
|
|
|
- Indexing low-cardinality columns (e.g., boolean) alone — not selective enough.
|
|
- Over-indexing — each index adds write overhead (INSERT/UPDATE/DELETE slower).
|
|
- Missing composite indexes for common query patterns.
|
|
- `SELECT *` pulling columns not covered by the index, forcing heap lookups.
|
|
- Function calls on indexed columns (`WHERE LOWER(email) = 'x'`) unless using a functional index.
|
|
|
|
---
|
|
|
|
## 3. N+1 Detection & Mitigation
|
|
|
|
### What Is N+1?
|
|
|
|
The N+1 selects problem occurs when an application issues 1 query to fetch N parent rows, then issues N additional queries to fetch related data for each parent — N+1 total queries instead of a single efficient query.
|
|
|
|
### Example (ORM-level Pseudocode)
|
|
|
|
```python
|
|
# N+1: 1 query for users + N queries for orders
|
|
users = User.query.all() # 1 query → 100 users
|
|
for user in users:
|
|
orders = user.orders # 100 queries!
|
|
...
|
|
```
|
|
|
|
```sql
|
|
-- Queries generated:
|
|
SELECT * FROM users; -- 1
|
|
SELECT * FROM orders WHERE user_id = 1; -- 2
|
|
SELECT * FROM orders WHERE user_id = 2; -- ...
|
|
SELECT * FROM orders WHERE user_id = 100; -- 101
|
|
```
|
|
|
|
### Detection Techniques
|
|
|
|
1. **ORM query logging** — enable SQL logging and watch for repeated similar queries.
|
|
2. **APM tools** — Scout, New Relic, Datadog highlight N+1 patterns automatically.
|
|
3. **Manual EXPLAIN** — detect many identical queries in a short window.
|
|
4. **Static analysis** — Rails' `bullet` gem, Django's `nplusone`, Java's `jpa-nplusone`.
|
|
5. **Database-side analysis** — `pg_stat_statements` showing high call counts.
|
|
|
|
### Mitigation Strategies
|
|
|
|
| Strategy | ORM | How |
|
|
|----------|-----|-----|
|
|
| **Eager loading (JOIN)** | Django `select_related` / Rails `includes` / Hibernate `JOIN FETCH` | Single query with JOIN |
|
|
| **Batch loading** | Django `prefetch_related` / Rails `preload` / Hibernate `@BatchSize` | Separate query per table, batched with `WHERE IN` |
|
|
| **GraphQL DataLoader** | Any GraphQL stack | Per-request batching & deduplication |
|
|
| **Lazy + batch** | Common in ORMs | Delay execution until accessed, then batch |
|
|
|
|
```python
|
|
# Fix with eager loading (Django)
|
|
users = User.objects.select_related('profile').prefetch_related('orders').all()
|
|
|
|
# Fix with DataLoader (GraphQL)
|
|
from promise import Promise
|
|
from promise.dataloader import DataLoader
|
|
|
|
class OrderLoader(DataLoader):
|
|
def batch_load_fn(self, user_ids):
|
|
orders = Order.objects.filter(user_id__in=user_ids)
|
|
return Promise.resolve([list(orders.filter(user_id=uid)) for uid in user_ids])
|
|
```
|
|
|
|
### When N+1 Is Acceptable
|
|
|
|
- Small, fixed N (e.g., < 10 related items).
|
|
- Admin panels or reports where latency is not critical.
|
|
- Cached results with low cache-miss volume.
|
|
|
|
---
|
|
|
|
## 4. Pagination Strategies — Cursor vs Offset vs Keyset
|
|
|
|
### Offset/Limit (Most Common, Least Scalable)
|
|
|
|
```sql
|
|
SELECT * FROM orders ORDER BY created_at DESC LIMIT 20 OFFSET 0;
|
|
SELECT * FROM orders ORDER BY created_at DESC LIMIT 20 OFFSET 20;
|
|
```
|
|
|
|
**Pros:**
|
|
- Simple to implement.
|
|
- Supports arbitrary page jumps (page 1, page 5, page 100).
|
|
- Intuitive for developers.
|
|
|
|
**Cons:**
|
|
- **Performance degrades with page depth** — OFFSET 100000 must scan/skip 100K rows.
|
|
- **Phantom reads / missing rows** — if rows are inserted/deleted between requests, items may appear on multiple pages or be skipped entirely.
|
|
- **Inconsistent under write load** — `OFFSET` changes meaning as data shifts.
|
|
|
|
### Cursor-Based Pagination (Most Scalable, API-First)
|
|
|
|
```sql
|
|
-- First page: no cursor
|
|
SELECT * FROM orders ORDER BY created_at DESC LIMIT 20;
|
|
|
|
-- Next page: use the last item's cursor value
|
|
SELECT * FROM orders
|
|
WHERE created_at < '2026-06-04T12:00:00Z' -- cursor value
|
|
ORDER BY created_at DESC LIMIT 20;
|
|
```
|
|
|
|
```json
|
|
// API response shape
|
|
{
|
|
"data": [...],
|
|
"pagination": {
|
|
"next_cursor": "eyJpZCI6MTIzNDUsImNyZWF0ZWRfYXQiOiIyMDI2LTA2LTA0VDEyOjAwOjAwWiJ9",
|
|
"has_more": true
|
|
}
|
|
}
|
|
```
|
|
|
|
**Pros:**
|
|
- **O(1) performance at any depth** — uses index seek, not scan+skip.
|
|
- **Consistent** — no phantom reads or missed rows; cursor marks a fixed position.
|
|
- **Resilient to write load** — insertion/deletion doesn't shift cursor position.
|
|
|
|
**Cons:**
|
|
- No arbitrary page jumping (only next/prev).
|
|
- Requires a unique, sortable column (usually an ID or timestamp).
|
|
- Cursor encoding/decoding overhead (base64, opaque tokens).
|
|
|
|
### Keyset Pagination (Seek Method)
|
|
|
|
```sql
|
|
-- Composite pagination on (created_at, id)
|
|
SELECT * FROM orders
|
|
WHERE (created_at, id) < ('2026-06-04T12:00:00Z', 12345)
|
|
ORDER BY created_at DESC, id DESC
|
|
LIMIT 20;
|
|
```
|
|
|
|
- Uses a composite index on `(created_at, id)`.
|
|
- Similar performance to cursor-based — index seek on the tuple.
|
|
- Requires a compound comparison and a multi-column index.
|
|
|
|
### Comparison Table
|
|
|
|
| Aspect | Offset/Limit | Cursor | Keyset |
|
|
|--------|-------------|--------|--------|
|
|
| **Performance at depth** | O(n) — degrades | O(1) — constant | O(1) — constant |
|
|
| **Random page access** | Yes | No | No |
|
|
| **Phantom reads** | Yes | No | No |
|
|
| **Consistency** | Unstable | Stable | Stable |
|
|
| **Implementation complexity** | Trivial | Medium | Low-Medium |
|
|
| **Requires sortable unique column** | No | Yes | Yes |
|
|
| **Write-aware** | No | Yes | Yes |
|
|
|
|
### Recommendation
|
|
|
|
| Use Case | Strategy |
|
|
|----------|----------|
|
|
| **Admin panels, small datasets** | Offset/Limit (fine for < 10K rows) |
|
|
| **Public APIs, infinite scroll** | Cursor (REST/GraphQL best practice) |
|
|
| **Time-series, logs, audit trails** | Cursor or Keyset on timestamp + ID |
|
|
| **Internal tools with DB pagination** | Keyset (lowest complexity, no cursor encoding) |
|
|
|
|
---
|
|
|
|
## 5. Transaction Boundary Design
|
|
|
|
### ACID Properties
|
|
|
|
| Property | Meaning |
|
|
|----------|---------|
|
|
| **Atomicity** | All or nothing — transaction either completes fully or has no effect |
|
|
| **Consistency** | Transaction leaves DB in a valid state (constraints preserved) |
|
|
| **Isolation** | Concurrent transactions do not interfere with each other |
|
|
| **Durability** | Committed changes persist through failures |
|
|
|
|
### Isolation Levels
|
|
|
|
| Level | Dirty Read | Non-Repeatable Read | Phantom Read | Serialization Anomaly |
|
|
|-------|-----------|--------------------|-------------|---------------------|
|
|
| **Read Uncommitted** | Possible | Possible | Possible | Possible |
|
|
| **Read Committed** (default in PostgreSQL, SQL Server, Oracle) | Safe | Possible | Possible | Possible |
|
|
| **Repeatable Read** | Safe | Safe | Possible (PG: safe) | Possible |
|
|
| **Serializable** | Safe | Safe | Safe | Safe |
|
|
|
|
**PostgreSQL specifics:**
|
|
- Default is **Read Committed**.
|
|
- Repeatable Read also prevents phantom reads (uses snapshot isolation).
|
|
- Serializable uses Serializable Snapshot Isolation (SSI) — detects serialization conflicts and aborts.
|
|
|
|
### Choosing an Isolation Level
|
|
|
|
```sql
|
|
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
|
|
-- or for the session:
|
|
SET default_transaction_isolation = 'repeatable read';
|
|
```
|
|
|
|
| Level | When to Use |
|
|
|-------|------------|
|
|
| **Read Committed** | Default for most workloads. Good balance of consistency and performance. |
|
|
| **Repeatable Read** | Financial calculations, reporting — when you need consistent snapshots. |
|
|
| **Serializable** | Critical data integrity (ledgers, inventory allocation). Higher abort rate. |
|
|
|
|
### Transaction Retry Patterns
|
|
|
|
**Optimistic retry (for Serializable / Repeatable Read conflicts):**
|
|
|
|
```
|
|
RETRY_COUNT = 0
|
|
MAX_RETRIES = 3
|
|
BACKOFF = [50ms, 150ms, 500ms]
|
|
|
|
WHILE RETRY_COUNT <= MAX_RETRIES:
|
|
BEGIN TRANSACTION
|
|
TRY:
|
|
-- business logic
|
|
COMMIT
|
|
BREAK
|
|
CATCH serialization_failure:
|
|
ROLLBACK
|
|
SLEEP(BACKOFF[RETRY_COUNT])
|
|
RETRY_COUNT += 1
|
|
CATCH deadlock:
|
|
ROLLBACK
|
|
SLEEP(random 0-100ms)
|
|
RETRY_COUNT += 1
|
|
|
|
IF RETRY_COUNT > MAX_RETRIES:
|
|
RAISE "Transaction failed after retries"
|
|
```
|
|
|
|
**Best practices:**
|
|
- Use **exponential backoff** with jitter to avoid thundering herd.
|
|
- Keep transactions **short** — minimize lock duration.
|
|
- **Read before write** — detect conflicts early inside the transaction.
|
|
- Use **optimistic locking** (version column) for entity-level concurrency instead of pessimistic locks when possible.
|
|
|
|
### Distributed Transactions
|
|
|
|
| Pattern | Description | When to Use |
|
|
|---------|-------------|------------|
|
|
| **Two-Phase Commit (2PC)** | Coordinator prepares all participants, then commits | Within a single database system only |
|
|
| **Saga (Choreography)** | Each service publishes events; compensating actions roll back | Microservices, async boundaries |
|
|
| **Saga (Orchestration)** | Central orchestrator sends commands and handles compensation | Complex multi-service workflows |
|
|
| **Outbox Pattern** | Write events to an outbox table in the same DB transaction, then async publish | Event-driven architecture with exactly-once guarantees |
|
|
| **Idempotency Keys** | Unique key per operation prevents duplicate processing | Payment handling, any external API call |
|
|
|
|
### Transaction Anti-Patterns
|
|
|
|
- **Long-running transactions** that hold locks — split into smaller units.
|
|
- **Nested transactions** across service boundaries — use Sagas instead.
|
|
- **Transaction inside a loop** — batch the work into a single transaction.
|
|
- **Mixing heavy I/O inside a transaction** — external API calls should happen before or after.
|
|
- **Not handling retries** for serialization failures — every Serializable workload needs retry logic.
|
|
|
|
---
|
|
|
|
## 6. Read/Write Splitting
|
|
|
|
### Architecture
|
|
|
|
```
|
|
┌─────────────────┐
|
|
│ Application │
|
|
│ (ORM / Client) │
|
|
└────┬────────┬───┘
|
|
│ │
|
|
Writes Reads
|
|
│ │
|
|
┌────▼──┐ ┌──▼────┐
|
|
│Primary│ │Replica│ ──→ (more replicas)
|
|
│(Write)│ │(Read) │
|
|
└───────┘ └───────┘
|
|
│ ↑
|
|
│ Async │
|
|
│ Repl. │
|
|
└──────────┘
|
|
```
|
|
|
|
### Implementation Approaches
|
|
|
|
| Approach | Mechanism | Pros | Cons |
|
|
|----------|-----------|------|------|
|
|
| **ORM-level** (`read_from=replica`) | Config in ORM (Django `DATABASES`, Rails `config`) | Simple; no infra change | Every service must configure manually |
|
|
| **Database Proxy** (ProxySQL, PgBouncer, PgCat) | Route based on query type | Centralized; no app changes | Extra hop; proxy becomes SPOF |
|
|
| **Middleware** (e.g., Spring `@Transactional(readOnly=true)`) | Annotation-driven routing | Fine-grained control; declarative | Framework-specific |
|
|
| **Client-side** (multi-DB driver config) | Connection string per role | Minimal infra | Deploy-time configuration |
|
|
|
|
### Query Routing Rules
|
|
|
|
```
|
|
Writes → Primary:
|
|
- INSERT, UPDATE, DELETE, MERGE
|
|
- DDL (CREATE TABLE, ALTER)
|
|
- SELECT ... FOR UPDATE (needs primary)
|
|
- SELECT inside a read-write transaction
|
|
|
|
Reads → Replica:
|
|
- SELECT (no locking)
|
|
- Read-only transactions (@Transactional(readOnly=true))
|
|
- Reporting queries, analytics
|
|
```
|
|
|
|
### When NOT to Read from Replicas
|
|
|
|
- **Read-after-write** queries — data may not have replicated yet.
|
|
- **Strong consistency** requirements (ledgers, inventory).
|
|
- **Tightly coupled** workflows where the next read depends on the previous write.
|
|
|
|
### Spring Boot Example (ReadWriteSplit Routing)
|
|
|
|
```java
|
|
@Transactional(readOnly = true)
|
|
public OrderDTO getOrder(Long id) { ... } // routed to replica
|
|
|
|
@Transactional
|
|
public OrderDTO createOrder(OrderDTO dto) { ... } // routed to primary
|
|
```
|
|
|
|
Configure `AbstractRoutingDataSource` with a `@ReadOnlyRepository` annotation or AOP advice to switch between primary and replica `DataSource`.
|
|
|
|
---
|
|
|
|
## 7. Replication Lag Handling
|
|
|
|
### The Problem
|
|
|
|
Asynchronously replicated databases always have some lag between write on the primary and visibility on replicas. This causes:
|
|
|
|
- **Read-after-write inconsistency** — user creates a resource, then immediately gets a 404 reading from a stale replica.
|
|
- **Monotonic read violation** — user sees a newer version of data, then an older version (from a different replica).
|
|
- **Causality violations** — entity A's state depends on entity B, but B's update hasn't arrived yet.
|
|
|
|
### Handling Strategies
|
|
|
|
| Strategy | Description | Complexity |
|
|
|----------|-------------|------------|
|
|
| **Read-your-writes (RYW)** | Route reads for recently-written data to the primary | Low |
|
|
| **Monotonic reads** | Route a session's reads to the same replica | Low |
|
|
| **Bounded staleness** | Reject reads from replicas lagging beyond a threshold | Medium |
|
|
| **Causal consistency (GTID)** | Track which transaction IDs the client has seen; ensure replica applies those before serving reads | Medium |
|
|
| **Wait-for-replication** | After write, wait for replica to catch up before serving reads | Medium |
|
|
| **Synchronous replication** | Primary waits for N replicas before committing | High (latency cost) |
|
|
|
|
### Read-Your-Writes (RYW) Pattern
|
|
|
|
```python
|
|
class DatabaseRouter:
|
|
def __init__(self):
|
|
self.recent_writes = {} # user_id → timestamp
|
|
|
|
def execute_write(self, user_id, query, params):
|
|
result = primary.execute(query, params)
|
|
self.recent_writes[user_id] = time.now()
|
|
return result
|
|
|
|
def execute_read(self, user_id, query, params):
|
|
last_write = self.recent_writes.get(user_id, 0)
|
|
if time.now() - last_write < 5: # 5-second window
|
|
return primary.execute(query, params) # use primary
|
|
else:
|
|
return replica.execute(query, params) # use replica
|
|
```
|
|
|
|
### Monotonic Read Consistency (Shopify Pattern)
|
|
|
|
Route all related reads to the **same replica** using a hash-based sticky selection:
|
|
|
|
```sql
|
|
/* consistent_read_id:user_42 */ SELECT * FROM orders WHERE user_id = 42;
|
|
```
|
|
|
|
```
|
|
Hash("user_42") % NUM_REPLICAS = replica_index → always hits the same server
|
|
```
|
|
|
|
**Trade-off:** Simple and low-overhead; occasional inconsistency if that replica goes down.
|
|
|
|
### Wait-for-Replication
|
|
|
|
```python
|
|
def write_and_wait(data):
|
|
primary.execute("INSERT INTO ...", data)
|
|
# Wait for the write to arrive on at least one replica
|
|
primary.execute("SELECT pg_current_wal_lsn()") # Postgres
|
|
# or use pg_stat_replication
|
|
|
|
def read_with_consistency(key):
|
|
# Check that replica has caught up to a known LSN
|
|
replica_lsn = replica.execute("SELECT pg_last_wal_replay_lsn()")
|
|
if replica_lsn >= required_lsn:
|
|
return replica.read(key)
|
|
else:
|
|
return primary.read(key) # fallback to primary
|
|
```
|
|
|
|
### Strategies by Use Case
|
|
|
|
| Use Case | Recommended Strategy |
|
|
|----------|---------------------|
|
|
| **User-facing web app after form submit** | Read-your-writes (route to primary for 5-30s) |
|
|
| **Social feed, timeline** | Monotonic reads (sessions stick to one replica) |
|
|
| **Analytics, reporting** | Bounded staleness acceptable; lag of minutes is fine |
|
|
| **Inventory, financial ledger** | Always read from primary (strong consistency) |
|
|
| **Notifications** | Accept eventual consistency; timestamp-driven dedup |
|
|
|
|
---
|
|
|
|
## 8. Service-Level Testing Overview
|
|
|
|
```
|
|
Coverage ▲
|
|
│
|
|
┌─────────┤
|
|
│ E2E │ Few, slow, expensive
|
|
┌───┤ Tests │
|
|
│ └─────────┤
|
|
┌───┤ │
|
|
│ │ Service │ Medium count, medium speed
|
|
┌───┤ │ (Integ.) │
|
|
│ │ └───────────┤
|
|
┌───┤ │ │
|
|
│ │ │ Unit │ Many, fast, cheap
|
|
│ │ │ Tests │
|
|
└───┴───┴──────────────┘
|
|
```
|
|
|
|
The **Test Pyramid** recommends:
|
|
- **Unit tests**: ~70% — fast, deterministic, test business logic in isolation.
|
|
- **Integration tests**: ~20% — test boundaries (DB, external APIs).
|
|
- **Contract tests**: ~5% — verify API agreements between services.
|
|
- **E2E tests**: ~5% — happy-path critical flows.
|
|
|
|
---
|
|
|
|
## 9. Unit Testing Business Logic
|
|
|
|
### Principles
|
|
|
|
- **Test in isolation** — mock/stub all collaborators (DB, file system, network).
|
|
- **Focus on logic** — test business rules, transformations, validations, and state changes.
|
|
- **Deterministic** — no flaky tests. No external dependencies.
|
|
- **Fast** — individual tests complete in milliseconds.
|
|
|
|
### What to Unit Test
|
|
|
|
```python
|
|
# GOOD: Pure business logic — test this
|
|
class OrderService:
|
|
def calculate_discount(self, order_total, customer_tier):
|
|
if customer_tier == 'vip':
|
|
return order_total * 0.20
|
|
elif order_total > 1000:
|
|
return order_total * 0.10
|
|
else:
|
|
return 0
|
|
|
|
# BAD: Impure — involves I/O, mock the boundary instead
|
|
class OrderController:
|
|
def create_order(self, request):
|
|
order = Order(...)
|
|
db.save(order) # this is an integration concern
|
|
notification.send(order) # mock this in unit tests
|
|
return order
|
|
```
|
|
|
|
### Repository/Data Layer Abstraction
|
|
|
|
Use the **Repository Pattern** to make business logic testable:
|
|
|
|
```java
|
|
// Business logic — unit testable with mock repository
|
|
public class OrderFulfillmentService {
|
|
private final OrderRepository orderRepo;
|
|
private final InventoryClient inventoryClient;
|
|
|
|
public FulfillmentResult fulfillOrder(String orderId) {
|
|
Order order = orderRepo.findById(orderId);
|
|
if (order == null) return FulfillmentResult.notFound();
|
|
|
|
boolean inStock = inventoryClient.checkStock(order.getSku(), order.getQuantity());
|
|
if (!inStock) return FulfillmentResult.outOfStock();
|
|
|
|
order.setStatus(OrderStatus.FULFILLED);
|
|
orderRepo.save(order);
|
|
return FulfillmentResult.success();
|
|
}
|
|
}
|
|
// Unit test: Mock orderRepo and inventoryClient, test all branches
|
|
```
|
|
|
|
### Testing Patterns
|
|
|
|
| Pattern | Description |
|
|
|---------|-------------|
|
|
| **Given-When-Then** | Arrange → Act → Assert structure |
|
|
| **Parameterized tests** | Test many input combinations with one test method |
|
|
| **Property-based testing** | Generate random inputs, assert invariants hold |
|
|
| **State-based vs Interaction-based** | Prefer state assertions over verifying mock interactions |
|
|
|
|
---
|
|
|
|
## 10. Integration Testing — API Contracts, Testcontainers, WireMock
|
|
|
|
### Testcontainers
|
|
|
|
**What:** Library that provides lightweight, disposable containers for testing (PostgreSQL, Redis, Kafka, etc.) as JUnit `@Rule` / `@Container`.
|
|
|
|
**Why real containers instead of in-memory:**
|
|
|
|
| Approach | Issues |
|
|
|----------|--------|
|
|
| **H2 (in-memory)** | Different SQL dialect, missing features, different behavior under load |
|
|
| **SQLite** | No JSONB, no PostGIS, no full-text search, different type coercion |
|
|
| **Testcontainers** | Real PostgreSQL/MySQL — 100% behavior match |
|
|
|
|
**Example (Java / Spring Boot + Testcontainers):**
|
|
|
|
```java
|
|
@SpringBootTest
|
|
@Testcontainers
|
|
class UserRepositoryIntegrationTest {
|
|
|
|
@Container
|
|
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16")
|
|
.withDatabaseName("testdb")
|
|
.withUsername("test")
|
|
.withPassword("test");
|
|
|
|
@DynamicPropertySource
|
|
static void configureProperties(DynamicPropertyRegistry registry) {
|
|
registry.add("spring.datasource.url", postgres::getJdbcUrl);
|
|
registry.add("spring.datasource.username", postgres::getUsername);
|
|
registry.add("spring.datasource.password", postgres::getPassword);
|
|
}
|
|
|
|
@Autowired
|
|
private UserRepository userRepository;
|
|
|
|
@Test
|
|
void shouldPersistAndRetrieveUser() {
|
|
User user = new User("alice@example.com", "Alice");
|
|
User saved = userRepository.save(user);
|
|
|
|
Optional<User> found = userRepository.findByEmail("alice@example.com");
|
|
assertThat(found).isPresent();
|
|
assertThat(found.get().getName()).isEqualTo("Alice");
|
|
}
|
|
}
|
|
```
|
|
|
|
**Testcontainers in other languages:**
|
|
|
|
| Language | Library |
|
|
|----------|---------|
|
|
| Python | `testcontainers` (pip) |
|
|
| Node.js | `testcontainers` (npm) |
|
|
| Go | `testcontainers-go` |
|
|
| .NET | `Testcontainers for .NET` |
|
|
| Rust | `testcontainers` (crate) |
|
|
|
|
### WireMock
|
|
|
|
**What:** HTTP-based API mock server. Stub external HTTP services during integration tests.
|
|
|
|
```java
|
|
@SpringBootTest
|
|
@WireMockTest(httpPort = 8089)
|
|
class PaymentServiceIntegrationTest {
|
|
|
|
@Test
|
|
void shouldProcessPaymentWhenGatewayRespondsSuccess() {
|
|
// Arrange: stub the external payment gateway
|
|
stubFor(post(urlEqualTo("/gateway/charge"))
|
|
.willReturn(aResponse()
|
|
.withStatus(200)
|
|
.withHeader("Content-Type", "application/json")
|
|
.withBody("""
|
|
{ "status": "success", "transaction_id": "txn_123" }
|
|
""")));
|
|
|
|
// Act
|
|
PaymentResult result = paymentService.charge(new Payment("user_1", 50.00));
|
|
|
|
// Assert
|
|
assertThat(result.isSuccess()).isTrue();
|
|
assertThat(result.getTransactionId()).isEqualTo("txn_123");
|
|
}
|
|
|
|
@Test
|
|
void shouldHandleGatewayTimeoutGracefully() {
|
|
stubFor(post(urlEqualTo("/gateway/charge"))
|
|
.willReturn(aResponse()
|
|
.withStatus(504)));
|
|
|
|
assertThrows(PaymentGatewayTimeoutException.class, () -> {
|
|
paymentService.charge(new Payment("user_1", 50.00));
|
|
});
|
|
}
|
|
}
|
|
```
|
|
|
|
**WireMock capabilities:**
|
|
- Stub based on URL, HTTP method, headers, body.
|
|
- Simulate delays, timeouts, and network failures.
|
|
- Record/playback (proxying real APIs during development).
|
|
- Verify requests were made (assert on expected interactions).
|
|
- Fault injection (malformed responses, connection resets).
|
|
|
|
### Integration Test Best Practices
|
|
|
|
1. **Test the boundary** — Repository tests with Testcontainers, external API tests with WireMock.
|
|
2. **Keep tests independent** — each test gets its own transaction or container state.
|
|
3. **Clean up between tests** — truncate tables or use transactional rollback.
|
|
4. **Use realistic data** — edge cases that trigger unique constraints, nulls, long strings.
|
|
5. **Don't test the framework** — you don't need to test that Hibernate/JPA/ActiveRecord works.
|
|
6. **Name tests by behavior** — `shouldRejectOrderWhenInventoryExhausted()`, never `testOrder1()`.
|
|
|
|
---
|
|
|
|
## 11. Contract Testing — Pact
|
|
|
|
### What Is Contract Testing?
|
|
|
|
Contract testing verifies that two services (consumer and provider) can communicate correctly by testing each side independently against a shared contract — without deploying both services.
|
|
|
|
### Pact Workflow
|
|
|
|
```
|
|
1. Consumer writes expectations (Pact file)
|
|
┌──────────┐ ┌──────────┐
|
|
│ Consumer │ ── generates ──→│ Pact │
|
|
│ Tests │ │ File │
|
|
└──────────┘ └────┬─────┘
|
|
│
|
|
2. Provider verifies against Pact │
|
|
┌──────────┐ │
|
|
│ Provider │ ←── verifies ────────│
|
|
│ Tests │ │
|
|
└──────────┘ │
|
|
│
|
|
3. Pact Broker stores & diff │
|
|
┌──────────────┐ │
|
|
│ Pact Broker │ ←── stores ──────│
|
|
│ (versioned) │ │
|
|
└──────┬───────┘ │
|
|
│ │
|
|
4. Can-I-Deploy checks versions │
|
|
┌──────────┐ │
|
|
│ CI/CD │ ←── compatibility ───│
|
|
└──────────┘ │
|
|
```
|
|
|
|
### Consumer-Side Test (Pact)
|
|
|
|
```java
|
|
@ExtendWith(PactConsumerTestExt.class)
|
|
@PactTestFor(providerName = "PaymentProvider", port = "8080")
|
|
class OrderServiceConsumerPactTest {
|
|
|
|
@Pact(consumer = "OrderService")
|
|
public V4Pact createPact(PactDslWithProvider builder) {
|
|
return builder
|
|
.given("a payment method exists with ID 'pm_1'")
|
|
.uponReceiving("a request to charge a payment")
|
|
.path("/gateway/charge")
|
|
.method("POST")
|
|
.headers("Content-Type", "application/json")
|
|
.body(new PactDslJsonBody()
|
|
.stringType("payment_method_id", "pm_1")
|
|
.decimalType("amount", 49.99)
|
|
)
|
|
.willRespondWith()
|
|
.status(200)
|
|
.headers("Content-Type", "application/json")
|
|
.body(new PactDslJsonBody()
|
|
.stringType("status", "success")
|
|
.stringType("transaction_id", "txn_abc123")
|
|
)
|
|
.toPact();
|
|
}
|
|
|
|
@Test
|
|
@PactTestFor(pactMethod = "createPact")
|
|
void shouldChargePaymentSuccessfully(MockServer mockServer) {
|
|
PaymentClient client = new PaymentClient(mockServer.getUrl());
|
|
PaymentResponse response = client.charge("pm_1", 49.99);
|
|
assertThat(response.getStatus()).isEqualTo("success");
|
|
}
|
|
}
|
|
```
|
|
|
|
### Provider-Side Verification
|
|
|
|
```java
|
|
@Provider("PaymentProvider")
|
|
@PactBroker(url = "${pactbroker.url}")
|
|
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
|
class PaymentProviderPactVerificationTest {
|
|
|
|
@LocalServerPort
|
|
int port;
|
|
|
|
@BeforeEach
|
|
void setup(PactVerificationContext context) {
|
|
context.setTarget(new HttpTestTarget("localhost", port));
|
|
}
|
|
|
|
@TestTemplate
|
|
@ExtendWith(PactVerificationInvocationContextProvider.class)
|
|
void pactVerificationTestTemplate(PactVerificationContext context) {
|
|
context.verifyInteraction();
|
|
}
|
|
|
|
@State("a payment method exists with ID 'pm_1'")
|
|
void setupPaymentMethod() {
|
|
// Set up test data — this runs before the provider is called
|
|
paymentMethodRepository.save(new PaymentMethod("pm_1", ...));
|
|
}
|
|
}
|
|
```
|
|
|
|
### Pact Best Practices
|
|
|
|
- **Version both consumer and provider** — Pact Broker tracks compatibility matrix.
|
|
- **Use `can-i-deploy`** — the `pact-broker can-i-deploy` command checks if two versions are compatible before deploying.
|
|
- **Don't over-specify** — use matchers (`stringType`, `decimalType`) instead of exact values for most fields. Exact values should only be for fields where the value matters (e.g., status enums).
|
|
- **Tag pacts by environment** — tag pact versions with "prod", "staging" to gate deployments.
|
|
- **Run provider verification in CI** — not just locally. Break the build if a provider change breaks a consumer contract.
|
|
|
|
---
|
|
|
|
## 12. Test Fixtures
|
|
|
|
### What Are Test Fixtures?
|
|
|
|
Test fixtures are predefined data setups that provide a known baseline state before tests run. They reduce duplication and make tests readable.
|
|
|
|
### Fixture Strategies
|
|
|
|
| Strategy | Description | Best For |
|
|
|----------|-------------|----------|
|
|
| **Inline (test-local)** | Create data directly in the test method | Simple, focused tests |
|
|
| **Factory methods** | Helper functions that create objects with sensible defaults | Most cases — flexible, composable |
|
|
| **Factory Boy / build()** | Use a library to generate test objects | Complex object graphs |
|
|
| **Seed SQL files** | Pre-populated SQL inserts loaded before test suite | Integration + E2E tests |
|
|
| **JSON/YAML snapshots** | Load test data from fixture files | When data is complex and nested |
|
|
|
|
### Example: Factory Pattern (Python)
|
|
|
|
```python
|
|
# factories.py
|
|
class UserFactory:
|
|
@staticmethod
|
|
def create(
|
|
email="test@example.com",
|
|
name="Test User",
|
|
tier="standard",
|
|
balance=Decimal("100.00")
|
|
):
|
|
return User(
|
|
email=email,
|
|
name=name,
|
|
tier=tier,
|
|
balance=balance
|
|
)
|
|
|
|
# test_discount.py
|
|
def test_vip_discount():
|
|
vip = UserFactory.create(tier="vip", balance=Decimal("500.00"))
|
|
result = discount_service.calculate(vip, 200)
|
|
assert result == Decimal("40.00") # 20% VIP discount
|
|
```
|
|
|
|
### Factory Boy (Python) / Builders (Java)
|
|
|
|
```python
|
|
import factory
|
|
|
|
class OrderFactory(factory.Factory):
|
|
class Meta:
|
|
model = Order
|
|
|
|
id = factory.Sequence(lambda n: n)
|
|
user = factory.SubFactory(UserFactory)
|
|
total = Decimal("100.00")
|
|
status = OrderStatus.PENDING
|
|
created_at = factory.Faker("date_time_this_year")
|
|
|
|
# Usage — only override what matters
|
|
order = OrderFactory.create(status=OrderStatus.FULFILLED)
|
|
assert order.user.email == "test@example.com" # default from UserFactory
|
|
```
|
|
|
|
### Fixture Anti-Patterns
|
|
|
|
- **Shared mutable fixtures** — tests that mutate shared state cause flaky ordering dependencies.
|
|
- **Too much data** — loading 1000 rows for every test is slow; use the minimum needed.
|
|
- **Copy-paste fixtures** — leads to drift; use factories with default values.
|
|
- **Magic numbers** — use named constants: `UNIT_PRICE = Decimal("10.00")` instead of bare `10.00`.
|
|
|
|
---
|
|
|
|
## 13. CI Integration
|
|
|
|
### Test Execution in CI
|
|
|
|
```
|
|
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌──────────────┐
|
|
│ Lint & │ │ Unit │ │ Integration │ │ Contract │
|
|
│ Static │ ──→ │ Tests │ ──→ │ Tests │ ──→ │ Tests / │
|
|
│ Analysis │ │ (fast, par) │ │ (slower) │ │ E2E Tests │
|
|
└─────────────┘ └─────────────┘ └─────────────┘ └──────────────┘
|
|
< 2 min < 5 min < 15 min < 30 min
|
|
```
|
|
|
|
### Parallelization
|
|
|
|
- **Unit tests** — run in parallel across CPU cores (pytest-xdist, JUnit parallel).
|
|
- **Integration tests** — parallel by service/module; isolate with Testcontainers per test class.
|
|
- **Contract tests** — consumer tests in parallel; provider tests sequentially per pact file.
|
|
|
|
### CI Pipeline Example (GitHub Actions)
|
|
|
|
```yaml
|
|
name: CI
|
|
on: [push, pull_request]
|
|
|
|
jobs:
|
|
unit-tests:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
- uses: actions/setup-java@v4
|
|
with: { java-version: '21', distribution: 'temurin' }
|
|
- run: ./gradlew test --parallel # unit tests only
|
|
|
|
integration-tests:
|
|
needs: unit-tests
|
|
runs-on: ubuntu-latest
|
|
services:
|
|
postgres:
|
|
image: postgres:16
|
|
env:
|
|
POSTGRES_PASSWORD: test
|
|
options: >-
|
|
--health-cmd pg_isready
|
|
--health-interval 10s
|
|
--health-timeout 5s
|
|
--health-retries 5
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
- run: ./gradlew integrationTest --tests *IntegrationTest
|
|
# Alternatively, use Testcontainers which starts containers in-test
|
|
|
|
contract-tests:
|
|
needs: unit-tests
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
- run: ./gradlew pactVerify # provider-side verification
|
|
- run: ./gradlew pactPublish # publish to Pact Broker
|
|
|
|
pact-can-i-deploy:
|
|
needs: contract-tests
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- run: pact-broker can-i-deploy
|
|
--pacticipant OrderService
|
|
--version $(cat version.txt)
|
|
--to-environment production
|
|
|
|
e2e-tests:
|
|
needs: [integration-tests, pact-can-i-deploy]
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- run: docker compose -f docker-compose.e2e.yml up --abort-on-container-exit
|
|
```
|
|
|
|
### CI Best Practices
|
|
|
|
| Practice | Rationale |
|
|
|----------|-----------|
|
|
| **Fail fast** | Run fastest tests first (unit → integration → E2E). |
|
|
| **Cache dependencies** | Maven/Gradle/npm/pip caches speed up repeat builds. |
|
|
| **Cache Docker layers** | Testcontainers pulls — pre-warm image caches. |
|
|
| **Isolate flaky tests** | Quarantine flaky tests; don't let them block the pipeline. |
|
|
| **Test against production-like databases** | Use Testcontainers with the same DB version as production. |
|
|
| **Run pact verification as a required check** | Never deploy a provider that breaks a consumer contract. |
|
|
| **Use test reports as artifacts** | Publish JUnit XML / HTML reports for debugging. |
|
|
|
|
### Test Run Optimization
|
|
|
|
- **Selective test execution** — only run tests for changed modules (gradle `--changed-latest`, `pytest --last-failed`).
|
|
- **Test splitting** — split integration tests across multiple CI runners (`--shard` flags).
|
|
- **Docker layer reuse** — Dockerfile changes cause full rebuilds; keep rarely-changed layers early.
|
|
- **Database migrations in CI** — run migrations once, snapshot the DB, restore for each test runner.
|
|
|
|
---
|
|
|
|
## References & Further Reading
|
|
|
|
- **PostgreSQL Documentation** — [EXPLAIN](https://www.postgresql.org/docs/current/using-explain.html), [Transaction Isolation](https://www.postgresql.org/docs/current/transaction-iso.html)
|
|
- **HikariCP** — [GitHub](https://github.com/brettwooldridge/HikariCP) (connection pool sizing)
|
|
- **PgBouncer** — [Official docs](https://www.pgbouncer.org/) (transaction pooling)
|
|
- **Pact** — [Documentation](https://docs.pact.io/) (contract testing)
|
|
- **Testcontainers** — [Official site](https://testcontainers.com/) (integration testing)
|
|
- **WireMock** — [Official site](https://wiremock.org/) (API mocking)
|
|
- **Shopify Engineering** — [Read Consistency with Database Replicas](https://shopify.engineering/read-consistency-database-replicas)
|
|
- **Crunchy Data** — [Postgres Indexes for Newbies](https://www.crunchydata.com/blog/postgres-indexes-for-newbies)
|
|
- **Scout APM** — [Understanding N+1 Database Queries](https://www.scoutapm.com/blog/understanding-n1-database-queries)
|
|
- **AWS** — [RDS Proxy](https://aws.amazon.com/rds/proxy/) (serverless connection pooling)
|