In the world of software development, adopting core programming principles can dramatically improve code quality, maintainability, and efficiency. Whether you're just starting your career as a developer or have years of experience under your belt, these principles provide a strong foundation for writing better code.
Below, we’ll dive into six key programming principles. They’re organized by complexity, starting with the simplest and moving toward more advanced concepts. For each, we’ll share examples demonstrating “bad,” “good,” and “best” practices.
1. The Boy Scout Rule
Concept: Leave the codebase cleaner than you found it.
The Boy Scout Rule is simple: improve the quality of the code you touch. This principle encourages developers to always improve the code they work on, even if it’s just minor enhancements like renaming variables, removing unused code, or breaking large functions into smaller pieces. These small improvements compound over time, leading to a healthier codebase.
Example:
Bad: Ignorning Poor Code
function fetchData() {
let result = fetch("https://api.example.com/data").then(response => response.json());
return result;
}
This lacks error handling and clarity.
Good: Refactoring for Readability
async function fetchData() {
const response = await fetch("https://api.example.com/data");
return await response.json();
}
Best: Handling Edge Cases
async function fetchData(): Promise<any> {
try {
const response = await fetch("https://api.example.com/data");
if (!response.ok) {
throw new Error("Network response was not ok");
}
return await response.json();
} catch (error) {
console.error("Failed to fetch data:", error);
throw error;
}
}
This version is cleaner, handles errors, and uses TypeScript for stronger typing.
2. KISS (Keep It Simple, Stupid)
Concept: Write simple, straightforward code that avoids unnecessary complexity.
When code becomes overly complicated, it’s more likely to contain bugs and harder to maintain. Always favor clarity and simplicity over being overly clever.
Example:
Bad: Overcomplicating a Sum Function
function sum(numbers) {
let total = 0;
for (let i = 0; i < numbers.length; i++) {
total += numbers[i];
}
return total;
}
console.log(sum([1, 2, 3])); // Output: 6
While this works, it's verbose for a simple operation.
Good: Simplifying the Function
function sum(numbers) {
return numbers.reduce((total, num) => total + num, 0);
}
console.log(sum([1, 2, 3])); // Output: 6
Best: Adding TypeScript for Clarity
function sum(numbers: number[]): number {
return numbers.reduce((total, num) => total + num, 0);
}
console.log(sum([1, 2, 3])); // Output: 6
3. DRY (Don’t Repeat Yourself)
Concept: Remove repetitive code by creating reusable functions or modules.
Repeated code not only increases maintenance effort but can also lead to inconsistencies over time. DRY helps you write cleaner and more efficient code.
Example:
Bad: Repetitive Code
function getUserById(id) {
return fetch(`https://api.example.com/users/${id}`);
}
function getProductById(id) {
return fetch(`https://api.example.com/products/${id}`);
}
Good: Abstracting Common Logic
function getById(entity, id) {
return fetch(`https://api.example.com/${entity}/${id}`);
}
Best: Adding TypeScript for Clarity
function getById<T>(entity: string, id: number): Promise<T> {
return fetch(`https://api.example.com/${entity}/${id}`).then(response => response.json());
}
By making the function generic, you can reuse it across different types.
4. Separation of Concerns (SoC)
Concept: Divide your code into distinct sections, each with its own responsibility.
This principle makes your code modular, reusable, and easier to maintain. By separating different concerns, you reduce interdependencies between code components.
Example:
Bad: Tightly Coupled Code
function getUserAndDisplay() {
fetch("https://api.example.com/users")
.then(response => response.json())
.then(data => console.log(data));
}
Good: Splitting Logic
function fetchUsers() {
return fetch("https://api.example.com/users").then(response => response.json());
}
function displayUsers(users) {
console.log(users);
}
fetchUsers().then(displayUsers);
Best: TypeScript with Modular Design
async function fetchUsers(): Promise<User[]> {
const response = await fetch("https://api.example.com/users");
return await response.json();
}
function displayUsers(users: User[]): void {
console.log(users);
}
fetchUsers().then(displayUsers);
Now, each function handles a single responsibility.
5. YAGNI (You Aren’t Gonna Need It)
Concept: Avoid adding functionality unless it is necessary.
It’s tempting to preemptively add features or properties that you think might be useful later, but this can lead to unnecessary complexity and bloat. Focus on what’s needed right now.
Example:
Bad: Over-Prepared Code
class User {
constructor(name, email) {
this.name = name;
this.email = email;
this.isAdmin = false; // Unused for now
}
}
Good: Lean Implementation
class User {
constructor(name, email) {
this.name = name;
this.email = email;
}
}
Best: Adding TypeScript for Clarity
class User {
constructor(public name: string, public email: string) {}
}
Unnecessary properties and boilerplate are removed.
6. SOLID Principles
The SOLID principles are a collection of advanced guidelines designed to help you write maintainable, scalable, and object-oriented code. Let’s break down each principle with examples.
Single Responsibility Principle (SRP)
Concept: A class should have only one reason to change.
Bad:
The User
class is responsible for both saving and validating, violating SRP.
class User {
save() {
// save logic
}
validate() {
// validation logic
}
}
Good:
The User
class focuses on the user itself, while UserService
handles saving logic.
class User {
// user properties
}
class UserService {
save(user) {
// save logic
}
}
Best:
TypeScript ensures strong typing for the User
object, making the design more robust.
class User {
constructor(public id: number, public name: string) {}
}
class UserService {
save(user: User): void {
// save logic
}
}
Open/Closed Principle (OCP)
Concept: Classes should be open for extension but closed for modification.
Bad:
Adding new payment types requires modifying the Payment
class.
class Payment {
process(type) {
if (type === 'credit') {
// credit logic
} else if (type === 'paypal') {
// PayPal logic
}
}
}
Good:
New payment types can be added as separate classes without modifying the existing ones.
class CreditPayment {
process() {
// credit logic
}
}
class PaypalPayment {
process() {
// PayPal logic
}
}
Best:
An interface ensures all payment types implement the process
method, making the system more extensible.
interface Payment {
process(): void;
}
class CreditPayment implements Payment {
process(): void {
// credit logic
}
}
class PaypalPayment implements Payment {
process(): void {
// PayPal logic
}
}
Liskov Substitution Principle (LSP)
Concept: Subtypes must be substitutable for their base types.
Bad:
The Penguin
class violates LSP because it doesn’t behave like other Bird
instances.
class Bird {
fly() {
// fly logic
}
}
class Penguin extends Bird {
fly() {
throw new Error('Penguins can’t fly!');
}
}
Good:
Separating flying and non-flying birds improves design.
class Bird {
// bird properties
}
class FlyingBird extends Bird {
fly() {
// fly logic
}
}
class Penguin extends Bird {
swim() {
// swim logic
}
}
Best:
Using abstract classes enforces a clear distinction between behaviors, adhering to LSP.
abstract class Bird {}
abstract class FlyingBird extends Bird {
abstract fly(): void;
}
class Sparrow extends FlyingBird {
fly(): void {
// fly logic
}
}
class Penguin extends Bird {
swim(): void {
// swim logic
}
}
Interface Segregation Principle (ISP)
Concept: Interfaces should only include methods relevant to the implementing class.
Bad:
The Worker
interface is too broad and forces robots to implement unrelated methods.
interface Worker {
work(): void;
eat(): void;
}
class Robot implements Worker {
work(): void {
// work logic
}
eat(): void {
throw new Error('Robots do not eat');
}
}
Good:
By splitting the interface, each class implements only the methods it needs.
interface Worker {
work(): void;
}
interface HumanWorker extends Worker {
eat(): void;
}
class Robot implements Worker {
work(): void {
// work logic
}
}
class Human implements HumanWorker {
work(): void {
// work logic
}
eat(): void {
// eat logic
}
}
Dependency Inversion Principle (DIP)
Concept: High-level modules should not depend on low-level modules. Both should depend on abstractions.
Bad:
The App
class depends directly on the Database
implementation.
class Database {
save(data) {
// save logic
}
}
class App {
constructor() {
this.database = new Database();
}
saveData(data) {
this.database.save(data);
}
}
Good:
By introducing an interface, the App
class is decoupled from specific database implementations.
interface Database {
save(data: any): void;
}
class SQLDatabase implements Database {
save(data: any): void {
// SQL save logic
}
}
class App {
constructor(private database: Database) {}
saveData(data: any): void {
this.database.save(data);
}
}
const app = new App(new SQLDatabase());
Conclusion
By applying these principles, you can write cleaner, more maintainable, and scalable code. Start with simple principles like the Boy Scout Rule and KISS, then gradually work your way toward mastering advanced concepts like SOLID.
Remember, these principles are tools meant to guide your development — not strict rules. Always adapt them to your specific project and team needs.
Discussion