ontracts/payment.interface.ts
export interface PaymentGateway {
processPayment(transactionId: string, amount: number, currency: string): Promise<PaymentResult>;
refund(transactionId: string, reason: string): Promise<RefundStatus>;
}
export interface PaymentLogger {
logTransaction(event: PaymentEvent): void;
logError(transactionId: string, error: Error): void;
}
**Architecture Rationale:** Interfaces define behavioral contracts, not implementation details. This allows swapping Stripe for Adyen, or switching from Winston to Pino, without modifying consumer logic. Orthogonality fails when modules import concrete implementations. Always depend on abstractions.
### Step 2: Apply DRY Through Shared Validation & Transformation
DRY (Don't Repeat Yourself) is violated when validation logic, serialization, or error mapping is duplicated across routes, services, or clients. Centralize transformation pipelines.
```typescript
// core/validation.ts
import { z } from 'zod';
export const PaymentRequestSchema = z.object({
transactionId: z.string().uuid(),
amount: z.number().positive(),
currency: z.enum(['USD', 'EUR', 'GBP']),
metadata: z.record(z.string()).optional()
});
export type PaymentRequest = z.infer<typeof PaymentRequestSchema>;
// core/transformers.ts
export function mapToGatewayPayload(request: PaymentRequest): GatewayPayload {
return {
id: request.transactionId,
amount_in_cents: Math.round(request.amount * 100),
currency_code: request.currency,
metadata: request.metadata ?? {}
};
}
Architecture Rationale: Validation and transformation become single points of truth. When business rules change, they update in one location. DRY is not about copying code; it's about consolidating intent. Schema-driven validation eliminates runtime type errors and documents contracts explicitly.
Step 3: Implement Tracer Bullets for Vertical Delivery
Tracer bullets deliver end-to-end functionality across all layers, validating architecture before scaling. Instead of building a complete payment service, ship a minimal vertical slice that touches routing, validation, gateway integration, logging, and testing.
// services/payment.service.ts
export class PaymentService {
constructor(
private readonly gateway: PaymentGateway,
private readonly logger: PaymentLogger,
private readonly validator: z.ZodType<PaymentRequest>
) {}
async execute(request: PaymentRequest): Promise<PaymentResult> {
const validated = this.validator.parse(request);
const payload = mapToGatewayPayload(validated);
try {
const result = await this.gateway.processPayment(payload.id, payload.amount_in_cents, payload.currency_code);
this.logger.logTransaction({ type: 'SUCCESS', transactionId: payload.id, timestamp: Date.now() });
return result;
} catch (error) {
this.logger.logError(payload.id, error as Error);
throw new PaymentProcessingError(error instanceof Error ? error.message : 'Unknown failure');
}
}
}
Architecture Rationale: Tracer bullets expose architectural friction early. If the gateway interface doesn't match real-world API constraints, you discover it during the first slice, not after three sprints of parallel development. Vertical delivery validates dependencies, error handling, and observability simultaneously.
Step 4: Automate Validation Pipelines
Pragmatism requires feedback loops. Manual testing is a liability. Automate unit, integration, and contract validation.
// tests/payment.service.test.ts
import { describe, it, expect, vi } from 'vitest';
import { PaymentService } from '../services/payment.service';
import { PaymentRequestSchema } from '../core/validation';
describe('PaymentService', () => {
it('rejects invalid currency', async () => {
const mockGateway = { processPayment: vi.fn(), refund: vi.fn() };
const mockLogger = { logTransaction: vi.fn(), logError: vi.fn() };
const service = new PaymentService(mockGateway, mockLogger, PaymentRequestSchema);
await expect(service.execute({ transactionId: '123', amount: 100, currency: 'XYZ' }))
.rejects.toThrow();
});
});
Architecture Rationale: Automated validation replaces tribal knowledge. Tests become living documentation. CI pipelines block merges on failing contracts, ensuring orthogonality and DRY discipline are enforced programmatically, not culturally.
Step 5: Distribute Knowledge via Code Reviews & Architecture Decision Records
The knowledge portfolio principle mandates that expertise is shared, not siloed. Implement lightweight Architecture Decision Records (ADRs) and enforce peer review on interface changes.
Architecture Rationale: Knowledge hoarding creates single points of failure. ADRs document why a gateway was chosen, how error boundaries are defined, and what trade-offs were accepted. Code reviews on contracts prevent silent coupling. This transforms individual expertise into system resilience.
Pitfall Guide
Production engineering exposes the gap between theory and execution. These are the most frequent failures when implementing pragmatic principles, drawn from real system migrations and team audits.
-
Over-abstracting DRY into premature generalization
Teams extract abstractions before observing repetition patterns. This creates indirection layers that obscure intent and increase cognitive load. DRY should emerge from duplication, not anticipate it. Wait for three concrete use cases before extracting shared logic.
-
False orthogonality through shared state
Modules appear independent but share mutable globals, environment variables, or singleton instances. This creates hidden coupling that breaks under concurrency or testing. Enforce stateless services and pass dependencies explicitly. Use dependency injection containers only when lifecycle management justifies the complexity.
-
Tracer bullets without failure boundaries
Vertical slices that lack error handling, retries, or circuit breakers mask architectural weaknesses. A tracer bullet must validate resilience, not just happy paths. Implement fallback strategies and timeout configurations during the first slice.
-
Automation without contract validation
CI pipelines that run linters but skip schema validation or integration tests provide false confidence. Automation must verify behavioral contracts, not just syntax. Integrate schema testing against real API responses or mock servers that enforce versioning.
-
Knowledge portfolio as documentation theater
Writing READMEs that describe what the code does instead of why it was designed that way creates maintenance debt. Documentation should capture trade-offs, failure modes, and extension points. Use ADRs for decisions, not tutorials.
-
Breaking dependencies incorrectly
Mocking everything in tests creates fragile suites that pass while production fails. Break dependencies at boundaries, not internals. Test integration points against real contracts; mock only external, unstable, or paid services.
-
Treating pragmatism as anti-best-practice
Pragmatism is not license to ignore testing, typing, or observability. It is deliberate engineering that balances delivery speed with system longevity. Teams that conflate pragmatism with expediency accumulate debt that compounds quarterly.
Best Practices from Production:
- Enforce interface contracts at compile time; validate at runtime.
- Keep modules under 300 lines; split when responsibilities diverge.
- Instrument tracer bullets with distributed tracing from day one.
- Treat test failures as architectural feedback, not CI noise.
- Review dependency graphs monthly; remove transitive coupling.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Startup MVP (< 3 devs) | Tracer bullets + minimal DRY | Speed requires vertical delivery; over-engineering kills iteration cycles | Low initial cost, moderate refactoring later |
| Enterprise legacy migration | Orthogonality + dependency inversion | Tight coupling prevents incremental replacement; interfaces enable strangler pattern | High upfront cost, drastic MTTR reduction |
| High-traffic payment service | Schema validation + contract testing + circuit breakers | Failure tolerance and idempotency are non-negotiable; automation prevents regression | Moderate infrastructure cost, near-zero change failure rate |
| Team scaling from 5 to 50 | ADRs + shared validation modules + CI gates | Knowledge distribution prevents bottlenecks; automated enforcement replaces cultural enforcement | High tooling investment, 40% reduction in onboarding time |
Configuration Template
// tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"forceConsistentCasingInFileNames": true,
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"sourceMap": true,
"skipLibCheck": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}
// package.json (scripts)
{
"scripts": {
"build": "tsc --project tsconfig.json",
"test": "vitest run --coverage",
"test:watch": "vitest",
"lint": "eslint src --ext .ts",
"lint:fix": "eslint src --ext .ts --fix",
"validate:contracts": "npx tsd --files src/contracts/*.ts",
"ci": "npm run lint && npm run validate:contracts && npm run test && npm run build"
}
}
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
coverage: {
provider: 'v8',
reporter: ['text', 'lcov'],
thresholds: {
lines: 85,
functions: 90,
branches: 80,
statements: 85
}
},
include: ['src/**/*.test.ts'],
exclude: ['node_modules', 'dist']
}
});
Quick Start Guide
- Scaffold the project: Run
npm init -y, install typescript, vitest, @vitest/coverage-v8, zod, and eslint. Apply the tsconfig.json and package.json scripts from the template.
- Initialize contracts: Create
src/contracts/ with interface definitions for core dependencies. Run npm run validate:contracts to ensure compile-time enforcement.
- Build the tracer bullet: Implement one vertical slice (e.g., payment processing) covering validation, service logic, gateway integration, and error handling. Write tests against the contract.
- Wire CI gates: Add
npm run ci to your GitHub Actions or GitLab CI pipeline. Configure branch protection to require passing validation, tests, and build before merging.
- Deploy observability: Add structured logging and trace IDs to the tracer bullet. Verify that errors are caught, logged, and measurable before scaling additional features.
Pragmatic engineering is not a philosophy. It is a production discipline. The lessons from The Pragmatic Programmer survive decades of framework churn because they address how systems actually fail: through coupling, duplication, unvalidated assumptions, and knowledge silos. Implement them as executable constraints, not aspirational guidelines. The metrics will follow.