Skip to main content

Core Engineering Principles

Why Principles Matter

Principles provide a shared vocabulary and decision framework for engineering teams. They aren't rigid rules but guidelines that help make consistent decisions across the codebase. When principles conflict, use judgment based on context - but document why you deviated.

Overview

This document establishes foundational engineering principles that guide all development work. These principles apply across languages, frameworks, and domains. They form the basis for code reviews, architectural decisions, and daily development practices. For language-specific applications, see the relevant language guides (Java, TypeScript, Kotlin, Swift).


Team Principles

Core Values

  • Consistency: Follow established patterns; don't reinvent conventions
  • Clarity > Brevity: Code is read more often than written; optimize for understanding
  • Fast Feedback Loop: Fail fast, test early, automate checks
  • Debuggability: Write code that's easy to trace and debug in production
  • Environment Stability: Reproducible builds and deployments
  • Automation: Automate repetitive tasks; reduce manual toil
  • Small Iterative Changes: Ship frequently in small increments

SOLID Principles

SOLID principles guide object-oriented design toward maintainable, flexible code. They work together - violating one often leads to violations of others.

Single Responsibility Principle (SRP)

A class should have only one reason to change. Each class or module should encapsulate a single piece of functionality.

//  BAD: Multiple responsibilities
class PaymentService {
processPayment(payment: Payment): void { /* ... */ }
generateReport(startDate: Date, endDate: Date): Report { /* ... */ }
sendEmailNotification(recipient: string, message: string): void { /* ... */ }
validateCreditCard(cardNumber: string): boolean { /* ... */ }
}

// GOOD: Single responsibility per class
class PaymentProcessor {
constructor(
private validator: PaymentValidator,
private gateway: PaymentGateway,
private notifier: PaymentNotifier
) {}

processPayment(payment: Payment): PaymentResult {
this.validator.validate(payment);
const result = this.gateway.submit(payment);
this.notifier.notify(payment, result);
return result;
}
}

class PaymentReportGenerator {
generateReport(startDate: Date, endDate: Date): Report { /* ... */ }
}

class EmailNotificationService {
send(recipient: string, message: string): void { /* ... */ }
}

Why It Matters: When a class has multiple responsibilities, changes to one responsibility risk breaking the others. SRP reduces coupling and makes testing easier - you can test each responsibility in isolation.

Open/Closed Principle (OCP)

Software entities should be open for extension but closed for modification. Add new behavior by adding new code, not changing existing code.

//  BAD: Must modify existing code for new payment types
class PaymentProcessor {
process(payment: Payment): void {
if (payment.type === 'credit_card') {
// Process credit card
} else if (payment.type === 'bank_transfer') {
// Process bank transfer
} else if (payment.type === 'crypto') {
// Process crypto - had to modify this class!
}
}
}

// GOOD: Extend behavior without modifying existing code
interface PaymentHandler {
canHandle(payment: Payment): boolean;
process(payment: Payment): PaymentResult;
}

class CreditCardHandler implements PaymentHandler {
canHandle(payment: Payment): boolean {
return payment.type === 'credit_card';
}
process(payment: Payment): PaymentResult { /* ... */ }
}

class BankTransferHandler implements PaymentHandler {
canHandle(payment: Payment): boolean {
return payment.type === 'bank_transfer';
}
process(payment: Payment): PaymentResult { /* ... */ }
}

// Add new payment type without modifying existing handlers
class CryptoHandler implements PaymentHandler {
canHandle(payment: Payment): boolean {
return payment.type === 'crypto';
}
process(payment: Payment): PaymentResult { /* ... */ }
}

class PaymentProcessor {
constructor(private handlers: PaymentHandler[]) {}

process(payment: Payment): PaymentResult {
const handler = this.handlers.find(h => h.canHandle(payment));
if (!handler) {
throw new UnsupportedPaymentTypeError(payment.type);
}
return handler.process(payment);
}
}

Why It Matters: Modifying existing code risks introducing bugs in tested, working code. OCP encourages extension points (interfaces, abstract classes, strategy pattern) that allow new behavior without touching existing implementations.

Liskov Substitution Principle (LSP)

Subtypes must be substitutable for their base types without altering program correctness. If S is a subtype of T, objects of type T can be replaced with objects of type S without breaking the program.

//  BAD: Square violates LSP when substituted for Rectangle
class Rectangle {
constructor(protected width: number, protected height: number) {}

setWidth(width: number): void {
this.width = width;
}

setHeight(height: number): void {
this.height = height;
}

getArea(): number {
return this.width * this.height;
}
}

class Square extends Rectangle {
setWidth(width: number): void {
this.width = width;
this.height = width; // Must maintain square invariant
}

setHeight(height: number): void {
this.height = height;
this.width = height; // Must maintain square invariant
}
}

// Breaks when substituting Square for Rectangle
function doubleWidth(rect: Rectangle): void {
const originalHeight = rect.getArea() / rect.width;
rect.setWidth(rect.width * 2);
// Expected: area doubles. But for Square, area quadruples!
}

// GOOD: Separate types, no inheritance relationship
interface Shape {
getArea(): number;
}

class Rectangle implements Shape {
constructor(private width: number, private height: number) {}

getArea(): number {
return this.width * this.height;
}

resize(width: number, height: number): Rectangle {
return new Rectangle(width, height);
}
}

class Square implements Shape {
constructor(private side: number) {}

getArea(): number {
return this.side * this.side;
}

resize(side: number): Square {
return new Square(side);
}
}

Why It Matters: LSP ensures polymorphism works correctly. Violating it leads to surprising behavior where code that works with a base type fails mysteriously with subtypes. If you find yourself checking types at runtime to handle special cases, you've likely violated LSP.

Interface Segregation Principle (ISP)

Clients should not be forced to depend on interfaces they don't use. Prefer small, focused interfaces over large, general-purpose ones.

//  BAD: Fat interface forces unnecessary dependencies
interface PaymentGateway {
processPayment(payment: Payment): PaymentResult;
refundPayment(paymentId: string): RefundResult;
getTransactionHistory(accountId: string): Transaction[];
generateMonthlyStatement(accountId: string): Statement;
processRecurringPayment(subscriptionId: string): PaymentResult;
cancelSubscription(subscriptionId: string): void;
}

// Simple payment processor must implement methods it doesn't need
class SimplePaymentProcessor implements PaymentGateway {
processPayment(payment: Payment): PaymentResult { /* ... */ }
refundPayment(paymentId: string): RefundResult {
throw new Error('Not supported');
}
getTransactionHistory(accountId: string): Transaction[] {
throw new Error('Not supported');
}
// ... forced to implement all methods
}

// GOOD: Segregated interfaces
interface PaymentProcessor {
processPayment(payment: Payment): PaymentResult;
}

interface RefundProcessor {
refundPayment(paymentId: string): RefundResult;
}

interface TransactionHistory {
getTransactionHistory(accountId: string): Transaction[];
}

interface SubscriptionManager {
processRecurringPayment(subscriptionId: string): PaymentResult;
cancelSubscription(subscriptionId: string): void;
}

// Implement only what you need
class SimplePaymentService implements PaymentProcessor {
processPayment(payment: Payment): PaymentResult { /* ... */ }
}

class FullPaymentGateway implements
PaymentProcessor,
RefundProcessor,
TransactionHistory,
SubscriptionManager {
// Implements all interfaces
}

Why It Matters: Fat interfaces create unnecessary coupling. When a client depends on methods it doesn't use, changes to those methods can break the client even though it doesn't use them. ISP reduces coupling and makes implementations simpler.

Dependency Inversion Principle (DIP)

High-level modules should not depend on low-level modules; both should depend on abstractions. Abstractions should not depend on details; details should depend on abstractions.

//  BAD: High-level module depends on low-level implementation
class PaymentService {
private repository = new PostgresPaymentRepository(); // Direct dependency
private emailService = new SmtpEmailService(); // Direct dependency

processPayment(payment: Payment): void {
this.repository.save(payment);
this.emailService.sendConfirmation(payment);
}
}

// GOOD: Depend on abstractions, inject implementations
interface PaymentRepository {
save(payment: Payment): void;
findById(id: string): Payment | undefined;
}

interface NotificationService {
sendConfirmation(payment: Payment): void;
}

class PaymentService {
constructor(
private repository: PaymentRepository, // Depends on abstraction
private notifier: NotificationService // Depends on abstraction
) {}

processPayment(payment: Payment): void {
this.repository.save(payment);
this.notifier.sendConfirmation(payment);
}
}

// Implementations depend on abstractions
class PostgresPaymentRepository implements PaymentRepository {
save(payment: Payment): void { /* ... */ }
findById(id: string): Payment | undefined { /* ... */ }
}

class EmailNotificationService implements NotificationService {
sendConfirmation(payment: Payment): void { /* ... */ }
}

// In tests, inject mocks
const mockRepository: PaymentRepository = {
save: jest.fn(),
findById: jest.fn()
};
const service = new PaymentService(mockRepository, mockNotifier);

Why It Matters: DIP enables testability (inject mocks), flexibility (swap implementations), and decoupling (high-level policy doesn't change when low-level details change). It's the foundation for dependency injection frameworks.


DRY (Don't Repeat Yourself)

Every piece of knowledge should have a single, unambiguous representation in the system. Duplication creates maintenance burden and inconsistency.

//  BAD: Duplicated validation logic
function validateCreatePayment(payment: CreatePaymentRequest): ValidationResult {
if (!payment.amount || payment.amount <= 0) {
return { valid: false, error: 'Amount must be positive' };
}
if (!payment.currency || payment.currency.length !== 3) {
return { valid: false, error: 'Currency must be 3-letter ISO code' };
}
return { valid: true };
}

function validateUpdatePayment(payment: UpdatePaymentRequest): ValidationResult {
if (!payment.amount || payment.amount <= 0) {
return { valid: false, error: 'Amount must be positive' };
}
if (!payment.currency || payment.currency.length !== 3) {
return { valid: false, error: 'Currency must be 3-letter ISO code' };
}
return { valid: true };
}

// GOOD: Single source of truth for validation rules
const paymentValidators = {
amount: (amount: number | undefined): string | null => {
if (!amount || amount <= 0) {
return 'Amount must be positive';
}
return null;
},

currency: (currency: string | undefined): string | null => {
if (!currency || currency.length !== 3) {
return 'Currency must be 3-letter ISO code';
}
return null;
}
};

function validatePaymentFields(
fields: Partial<Payment>,
requiredFields: (keyof typeof paymentValidators)[]
): ValidationResult {
for (const field of requiredFields) {
const error = paymentValidators[field](fields[field]);
if (error) {
return { valid: false, error };
}
}
return { valid: true };
}

// Use the same validators for both cases
const createResult = validatePaymentFields(createRequest, ['amount', 'currency']);
const updateResult = validatePaymentFields(updateRequest, ['amount', 'currency']);

Caveats: Not all duplication is bad. If two pieces of code happen to look similar but represent different concepts, keeping them separate is correct - they'll likely diverge over time. DRY is about knowledge, not text. Ask: "If this business rule changes, how many places do I need to update?"


KISS (Keep It Simple, Stupid)

Choose the simplest solution that solves the problem. Complexity is a cost that must be justified by benefits.

//  BAD: Over-engineered for the problem
interface PaymentAmountCalculatorStrategy {
calculate(base: number, modifiers: PaymentModifier[]): number;
}

class DefaultPaymentAmountCalculator implements PaymentAmountCalculatorStrategy {
calculate(base: number, modifiers: PaymentModifier[]): number {
return modifiers.reduce((amount, modifier) =>
modifier.apply(amount), base);
}
}

class PaymentAmountCalculatorFactory {
create(type: string): PaymentAmountCalculatorStrategy {
switch (type) {
case 'default':
return new DefaultPaymentAmountCalculator();
default:
throw new Error(`Unknown calculator type: ${type}`);
}
}
}

// Used only for: amount + fee
const calculator = new PaymentAmountCalculatorFactory().create('default');
const total = calculator.calculate(100, [new FeeModifier(2.50)]);

// GOOD: Simple solution for simple problem
function calculateTotal(amount: number, fee: number): number {
return amount + fee;
}

const total = calculateTotal(100, 2.50);

When to Add Complexity: Add abstractions when you have concrete evidence of need - multiple implementations, likely change, or clear separation of concerns. Don't add flexibility "just in case."


YAGNI (You Aren't Gonna Need It)

Don't build features until they're needed. Speculative features add complexity, maintenance burden, and often go unused.

//  BAD: Building for hypothetical requirements
interface PaymentService {
processPayment(payment: Payment): Promise<PaymentResult>;
processBatchPayments(payments: Payment[]): Promise<PaymentResult[]>; // Never used
schedulePayment(payment: Payment, date: Date): Promise<void>; // Never used
cancelScheduledPayment(id: string): Promise<void>; // Never used
processRecurringPayment(subscription: Subscription): Promise<PaymentResult>; // Never used
}

// GOOD: Build what you need now
interface PaymentService {
processPayment(payment: Payment): Promise<PaymentResult>;
}

// Add batch processing when actually needed, not before
// interface PaymentService {
// processPayment(payment: Payment): Promise<PaymentResult>;
// processBatchPayments(payments: Payment[]): Promise<PaymentResult[]>;
// }

Why It Matters:

  • Unused code is still maintained, tested, and debugged
  • Speculative abstractions often miss actual requirements
  • It's easier to add features to simple code than to remove features from complex code

Clean Code Principles

Meaningful Names

Names should reveal intent. Avoid mental mapping and unclear abbreviations.

//  BAD: Unclear names
const d = 5; // elapsed time in days?
const lst = getPayments();
const pmt = lst[0];
function calc(a: number, b: number): number { /* ... */ }

// GOOD: Intent-revealing names
const elapsedDays = 5;
const recentPayments = getPayments();
const latestPayment = recentPayments[0];
function calculateFee(amount: number, feeRate: number): number { /* ... */ }

Guidelines:

  • Use domain vocabulary: accountBalance, not bal
  • Be specific: paymentProcessingService, not service
  • Avoid noise words: paymentDatapayment, accountInfoaccount
  • Use pronounceable names: generationTimestamp, not genymdhms

Functions Should Do One Thing

Each function should have a single level of abstraction and do one thing well.

//  BAD: Multiple levels of abstraction
async function processPayment(paymentRequest: PaymentRequest): Promise<void> {
// Validation (low-level)
if (!paymentRequest.amount || paymentRequest.amount <= 0) {
throw new ValidationError('Invalid amount');
}
if (!paymentRequest.currency || paymentRequest.currency.length !== 3) {
throw new ValidationError('Invalid currency');
}

// Database access (low-level)
const account = await db.query(
'SELECT * FROM accounts WHERE id = $1',
[paymentRequest.accountId]
);

// Business logic (high-level)
if (account.balance < paymentRequest.amount) {
throw new InsufficientFundsError();
}

// More database access (low-level)
await db.query(
'UPDATE accounts SET balance = balance - $1 WHERE id = $2',
[paymentRequest.amount, paymentRequest.accountId]
);

// External API call (low-level)
await fetch('https://api.payment.com/process', {
method: 'POST',
body: JSON.stringify(paymentRequest)
});

// Logging (low-level)
console.log(`Payment processed: ${paymentRequest.id}`);
}

// GOOD: Single level of abstraction per function
async function processPayment(paymentRequest: PaymentRequest): Promise<void> {
validatePaymentRequest(paymentRequest);
const account = await accountRepository.findById(paymentRequest.accountId);
ensureSufficientFunds(account, paymentRequest.amount);
await debitAccount(account, paymentRequest.amount);
await submitToPaymentGateway(paymentRequest);
logger.info('Payment processed', { paymentId: paymentRequest.id });
}

Avoid Side Effects

Functions should either do something (command) or answer something (query), not both.

//  BAD: Query with side effect
function getBalance(accountId: string): number {
const balance = accountRepository.getBalance(accountId);
lastAccessTime.set(accountId, new Date()); // Hidden side effect!
return balance;
}

// GOOD: Separate query from command
function getBalance(accountId: string): number {
return accountRepository.getBalance(accountId);
}

function recordAccountAccess(accountId: string): void {
lastAccessTime.set(accountId, new Date());
}

Prefer Pure Functions

Pure functions are deterministic (same input → same output) and have no side effects. They're easier to test, reason about, and parallelize.

//  BAD: Impure function
let taxRate = 0.08;

function calculateTotal(amount: number): number {
return amount * (1 + taxRate); // Depends on external state
}

// GOOD: Pure function
function calculateTotal(amount: number, taxRate: number): number {
return amount * (1 + taxRate);
}

// GOOD: Pure function with immutable data
function applyDiscount(
payment: Payment,
discountPercent: number
): Payment {
return {
...payment,
amount: payment.amount * (1 - discountPercent / 100)
};
}

Prefer Immutability

Immutable data prevents accidental mutation and makes code easier to reason about.

//  BAD: Mutable data
function addPayment(payments: Payment[], newPayment: Payment): void {
payments.push(newPayment); // Mutates input array
}

// GOOD: Immutable operations
function addPayment(payments: Payment[], newPayment: Payment): Payment[] {
return [...payments, newPayment]; // Returns new array
}

// GOOD: Immutable update
function updatePaymentStatus(
payment: Payment,
status: PaymentStatus
): Payment {
return { ...payment, status };
}

Guard Clauses and Early Returns

Handle edge cases early to reduce nesting and improve readability.

//  BAD: Deep nesting
function processPayment(payment: Payment | null): PaymentResult {
if (payment) {
if (payment.amount > 0) {
if (payment.status === 'pending') {
// Actual logic buried in nesting
return executePayment(payment);
} else {
throw new InvalidStatusError(payment.status);
}
} else {
throw new InvalidAmountError(payment.amount);
}
} else {
throw new NullPaymentError();
}
}

// GOOD: Guard clauses with early returns
function processPayment(payment: Payment | null): PaymentResult {
if (!payment) {
throw new NullPaymentError();
}

if (payment.amount <= 0) {
throw new InvalidAmountError(payment.amount);
}

if (payment.status !== 'pending') {
throw new InvalidStatusError(payment.status);
}

// Happy path is clear and unindented
return executePayment(payment);
}

Initialize Variables Close to Usage

Declare variables near where they're used to improve readability and reduce scope.

//  BAD: Variables declared far from usage
function processPayments(payments: Payment[]): Summary {
let total = 0;
let count = 0;
let failed = 0;
let successful = 0;
const results: PaymentResult[] = [];

// ... 50 lines of code ...

for (const payment of payments) {
// Now using the variables
}
}

// GOOD: Variables declared near usage
function processPayments(payments: Payment[]): Summary {
const results: PaymentResult[] = [];

for (const payment of payments) {
const result = processPayment(payment);
results.push(result);
}

const successful = results.filter(r => r.status === 'success').length;
const failed = results.length - successful;
const total = results.reduce((sum, r) => sum + r.amount, 0);

return { total, successful, failed };
}

Code Quality Guidelines

Security

  • Always use trusted server time, not client time
  • Validate sequence of operations (are journey steps done in order?)
  • Zero trust model: validate on server even if validated on client
  • Data can be tampered; never trust client input
  • Maintain transactional and semantic validation on server only

See Security Overview for comprehensive security guidelines.

API Integrations

  • Configure timeouts and retries with exponential backoff
  • Use OpenAPI specs (not RAML) for contracts
  • Validate contracts with automated testing
  • Handle partial failures gracefully in distributed systems

See API Overview and API Patterns for API design patterns.

Comments

  • Use comments to explain why, not what
  • Include ticket references for additional context
  • Good code is self-explanatory; comments provide context that code cannot
  • Keep comments up to date; outdated comments are worse than no comments
//  BAD: Explains what (obvious from code)
// Increment counter by 1
counter++;

// GOOD: Explains why
// Compensate for off-by-one in legacy API response
counter++;

// GOOD: Business context
// JIRA-1234: Temporary workaround for provider API rate limiting
// Remove when provider upgrades to v2 API in Q2
await delay(500);

Performance

  • Define SLAs before optimizing
  • Measure before you optimize; profiling, not guessing
  • Use performance testing tools (Gatling, k6) with automated checks
  • Track performance over time, not only when problems appear
  • Use lazy loading and caching when appropriate

See Performance Overview for performance guidelines.

Testing

  • No code should be merged without passing tests
  • Prefer integration tests over excessive unit tests with mocks
  • Follow testing honeycomb: focus on integration tests
  • Avoid flaky tests; remove if issues cannot be resolved
  • Use service virtualization as a last resort
  • Maintain reasonable test runtime for fast feedback
  • Test edge cases and exceptions, not just happy paths
  • Validate null, empty, and invalid inputs, especially for public APIs

See Testing Strategy for comprehensive testing guidelines.


Further Reading

Internal Documentation

External Resources


Summary

Key Takeaways

  1. SOLID principles guide maintainable object-oriented design
  2. DRY eliminates knowledge duplication, not text duplication
  3. KISS favors simplicity; complexity requires justification
  4. YAGNI prevents speculative features; build what you need now
  5. Clean Code emphasizes readability: meaningful names, small functions, clear intent
  6. Immutability and pure functions make code easier to test and reason about
  7. Guard clauses reduce nesting and clarify happy paths
  8. Principles sometimes conflict; use judgment and document decisions

These principles form the foundation for all engineering work. Apply them consistently but pragmatically - the goal is maintainable, working software, not dogmatic adherence to rules.