Subscribe To Our Newsletter
You will receive our latest post and tutorial.
Thank you for subscribing!

required
required


Automation

Why Automation Matters

Every manual task you repeat is a bug waiting to happen. Automation eliminates human error, saves time, and lets you focus on solving real problems instead of running the same commands over and over. As a rule of thumb: if you do it more than twice, automate it.

Automation spans the entire software lifecycle — building, testing, deploying, and maintaining code. Let’s walk through each area with practical examples.

Build Automation

Build automation ensures your project compiles, resolves dependencies, and packages consistently every time — regardless of who runs it or where.

Java (Maven) — Define your build once in pom.xml:

mvn clean package -DskipTests
mvn dependency:resolve

Python (pip + setuptools) — Use a requirements.txt and a build command:

pip install -r requirements.txt
python setup.py sdist bdist_wheel

The key principle: no one should need tribal knowledge to build your project. Clone the repo, run one command, and it works.

CI/CD Pipelines

Continuous Integration and Continuous Deployment (CI/CD) automate the process of testing and shipping code every time you push. GitHub Actions is a popular choice:

# .github/workflows/ci.yml
name: CI Pipeline
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  build-and-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up Java
        uses: actions/setup-java@v4
        with:
          distribution: 'temurin'
          java-version: '17'

      - name: Build & Test
        run: mvn clean verify

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.11'

      - name: Install & Test Python
        run: |
          pip install -r requirements.txt
          pytest --cov=src tests/

With this in place, every push triggers a build and test run. Broken code never silently reaches production.

Automated Testing

Tests are the backbone of safe automation. Without them, your CI/CD pipeline is just fast-tracking bugs to production.

Java (JUnit 5):

@Test
void calculateTotal_shouldApplyDiscount() {
    Order order = new Order(100.0, 0.15);
    assertEquals(85.0, order.calculateTotal(), 0.01);
}

Python (pytest):

def test_calculate_total_applies_discount():
    order = Order(amount=100.0, discount=0.15)
    assert order.calculate_total() == 85.0

Automate your tests in CI so they run on every commit. For a deeper dive, check out the Test Coverage post in this series.

Code Quality Tools

Linters and formatters enforce consistent standards without code reviews catching style issues. Automate them so quality checks happen before code is even committed.

Java — Use Checkstyle and SpotBugs:

# Add to Maven build
mvn checkstyle:check
mvn spotbugs:check

Python — Use flake8 for linting and black for formatting:

flake8 src/ --max-line-length=120
black src/ --check

These tools catch bugs, enforce style, and remove subjective debates from code reviews. Integrate them into your CI pipeline so every pull request is checked automatically.

Deployment Automation

Manual deployments are risky. One missed step and your production environment breaks. Docker and simple scripts solve this.

Dockerfile for a Java Spring Boot app:

FROM eclipse-temurin:17-jre
COPY target/app.jar /app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app.jar"]

Python deployment script:

import subprocess
import sys

def deploy(env: str):
    steps = [
        f"docker build -t myapp:{env} .",
        f"docker tag myapp:{env} registry.example.com/myapp:{env}",
        f"docker push registry.example.com/myapp:{env}",
    ]
    for step in steps:
        result = subprocess.run(step, shell=True)
        if result.returncode != 0:
            print(f"Deploy failed at: {step}")
            sys.exit(1)
    print(f"Deployed to {env} successfully.")

if __name__ == "__main__":
    deploy(sys.argv[1])

The goal: deploying should be a single command, not a checklist.

Task Automation: Pre-commit Hooks and Makefiles

Catch problems before they reach your repository with pre-commit hooks:

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/psf/black
    rev: 24.4.2
    hooks:
      - id: black
  - repo: https://github.com/pycqa/flake8
    rev: 7.0.0
    hooks:
      - id: flake8

Use a Makefile to unify commands across your team:

# Makefile
.PHONY: build test lint deploy

build:
	mvn clean package -q

test:
	mvn test && pytest tests/

lint:
	mvn checkstyle:check && flake8 src/

deploy:
	docker build -t myapp . && docker push registry.example.com/myapp

Now every developer on your team runs the same commands: make build, make test, make deploy. No guesswork.

Senior Tip: The “Rule of Three”

Do something once — fine, do it manually. Do it twice — note it down. Do it a third time — stop and automate it.

  • Setting up dev environments? Write a Docker Compose file.
  • Running the same test commands? Add them to CI.
  • Deploying with a list of manual steps? Create a deploy script.
  • Formatting code before commits? Set up pre-commit hooks.

Automation is not about being lazy — it’s about being reliable. Machines don’t forget steps, don’t make typos, and don’t get tired on a Friday afternoon deploy. Invest time upfront in automation, and it pays dividends every single day.

December 2, 2019

Test Coverage

Why Testing Matters

Tests are the safety net of your codebase. Without them, every change is a gamble — you push code and hope nothing breaks. With a solid test suite, you refactor with confidence, onboard new developers faster, and catch regressions before they reach production. In professional engineering teams, untested code is considered unfinished code.

The Test Pyramid

Not all tests are equal. The test pyramid gives you a practical strategy for where to invest your testing effort:

  • Unit Tests (base) — Fast, isolated, test a single function or class. You should have the most of these.
  • Integration Tests (middle) — Verify that components work together (e.g., service + database, API calls).
  • End-to-End Tests (top) — Simulate real user workflows. Slow and brittle, so keep these minimal.

The rule of thumb: if a bug can be caught by a unit test, don’t write an integration test for it. Push tests as far down the pyramid as possible.

Unit Testing — Java (JUnit 5)

Unit tests verify a single piece of logic in isolation. Here is a straightforward example using JUnit 5:

public class PriceCalculator {
    public double applyDiscount(double price, double discountPercent) {
        if (discountPercent < 0 || discountPercent > 100) {
            throw new IllegalArgumentException("Invalid discount");
        }
        return price - (price * discountPercent / 100);
    }
}

// Test class
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

class PriceCalculatorTest {

    private final PriceCalculator calculator = new PriceCalculator();

    @Test
    void shouldApplyTenPercentDiscount() {
        double result = calculator.applyDiscount(100.0, 10);
        assertEquals(90.0, result);
    }

    @Test
    void shouldThrowForNegativeDiscount() {
        assertThrows(IllegalArgumentException.class,
            () -> calculator.applyDiscount(100.0, -5));
    }
}

Unit Testing — Python (pytest)

The same concept in Python using pytest:

class PriceCalculator:
    def apply_discount(self, price: float, discount_percent: float) -> float:
        if discount_percent < 0 or discount_percent > 100:
            raise ValueError("Invalid discount")
        return price - (price * discount_percent / 100)

# test_price_calculator.py
import pytest
from price_calculator import PriceCalculator

calculator = PriceCalculator()

def test_should_apply_ten_percent_discount():
    result = calculator.apply_discount(100.0, 10)
    assert result == 90.0

def test_should_throw_for_negative_discount():
    with pytest.raises(ValueError):
        calculator.apply_discount(100.0, -5)

Integration Testing

Integration tests verify that multiple components work together correctly. Common scenarios include testing a service layer that talks to a database, or an API endpoint that coordinates multiple services.

@SpringBootTest
@AutoConfigureMockMvc
class OrderControllerIntegrationTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    void shouldCreateOrderAndReturnCreatedStatus() throws Exception {
        String orderJson = "{\"item\": \"Laptop\", \"quantity\": 1}";

        mockMvc.perform(post("/api/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content(orderJson))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.item").value("Laptop"));
    }
}

The key difference: unit tests mock everything external, integration tests let real components interact.

What Makes a Good Test

Follow the Arrange-Act-Assert pattern in every test:

  • Arrange — Set up the data and dependencies.
  • Act — Execute the behavior under test.
  • Assert — Verify the expected outcome.

Other principles that matter:

  • Descriptive namesshouldReturnZeroWhenCartIsEmpty() beats test1().
  • One assertion per concept — Test one behavior at a time.
  • Independent tests — No test should depend on another test running first.
  • Fast execution — Slow tests get skipped. Keep unit tests under milliseconds.

Code Coverage — Aim for Meaningful, Not 100%

Code coverage measures what percentage of your code is exercised by tests. Tools like JaCoCo (Java) and coverage.py (Python) generate these reports.

A reasonable target is 70-80% line coverage. Chasing 100% leads to brittle tests on trivial code (getters, setters, boilerplate). Focus your testing effort on:

  • Business logic and calculations
  • Edge cases and error handling
  • Code that changes frequently
  • Anything that previously had a bug

Coverage tells you what is not tested. It does not tell you if your tests are any good. A test with no assertions still counts as coverage.

Mocking Dependencies

When your code depends on external services, databases, or APIs, you mock those dependencies so your test stays fast and isolated.

Java — Mockito:

@ExtendWith(MockitoExtension.class)
class OrderServiceTest {

    @Mock
    private PaymentGateway paymentGateway;

    @InjectMocks
    private OrderService orderService;

    @Test
    void shouldCompleteOrderWhenPaymentSucceeds() {
        // Arrange
        when(paymentGateway.charge(anyDouble())).thenReturn(true);

        // Act
        boolean result = orderService.placeOrder(new Order("Laptop", 999.99));

        // Assert
        assertTrue(result);
        verify(paymentGateway).charge(999.99);
    }
}

Python — unittest.mock:

from unittest.mock import Mock, patch
from order_service import OrderService

def test_should_complete_order_when_payment_succeeds():
    # Arrange
    mock_gateway = Mock()
    mock_gateway.charge.return_value = True
    service = OrderService(payment_gateway=mock_gateway)

    # Act
    result = service.place_order({"item": "Laptop", "price": 999.99})

    # Assert
    assert result is True
    mock_gateway.charge.assert_called_once_with(999.99)

Senior Tip: Test Behavior, Not Implementation

The biggest mistake developers make is testing how code works instead of what it does. If you refactor internals and your tests break — even though behavior is unchanged — your tests are too tightly coupled to implementation.

Bad: Asserting that a specific private method was called three times.

Good: Asserting that given input X, the output is Y.

Your tests should answer one question: “Does this code produce the correct result for the given input?” If you can swap out the implementation and your tests still pass, you have written good tests.

Invest in testing early. The cost of writing tests is always less than the cost of debugging production at 2 AM.

December 2, 2019

Naming Conventions

Why Naming Matters

Code is read far more often than it is written. The names you choose for classes, methods, variables, and constants are the first layer of documentation your teammates encounter. Good names eliminate the need for comments, reduce onboarding time, and prevent bugs caused by misunderstanding what a piece of code does.

As Phil Karlton famously said: “There are only two hard things in Computer Science: cache invalidation and naming things.”

Let’s walk through the naming conventions every professional developer should follow, with examples in Java and Python.

Class and Type Naming

Classes represent things — entities, services, concepts. Use PascalCase (every word capitalized, no underscores). The name should be a noun or noun phrase that clearly describes what the class represents.

// Java - Good
public class UserAccount { }
public class PaymentProcessor { }
public class HttpRequestHandler { }

// Bad
public class useraccount { }   // no casing
public class Mgr { }           // cryptic abbreviation
public class DataStuff { }     // vague
# Python - Same convention for classes
class UserAccount:
    pass

class PaymentProcessor:
    pass

# Bad
class user_account:   # This is for modules, not classes
    pass

Method and Function Naming

Methods represent actions. They should start with a verb and clearly describe what they do. Java uses camelCase, while Python uses snake_case.

// Java - camelCase, starts with a verb
public User findUserById(Long id) { }
public void sendWelcomeEmail(User user) { }
public boolean isEligibleForDiscount(Order order) { }

// Bad
public User user(Long id) { }        // no verb
public void process(Object o) { }    // too vague
# Python - snake_case, starts with a verb
def find_user_by_id(user_id: int) -> User:
    pass

def send_welcome_email(user: User) -> None:
    pass

# Bad
def data(x):      # no verb, unclear parameter
    pass

Variable Naming

Variables should describe the data they hold. Use meaningful names and avoid single-letter variables (except in trivial loops). Never abbreviate unless the abbreviation is universally understood (e.g., url, id, http).

// Java - Good: descriptive, self-documenting
String customerEmail = "john@example.com";
int maxRetryAttempts = 3;
List<Order> pendingOrders = orderService.findPending();

// Bad: cryptic, abbreviated, meaningless
String ce = "john@example.com";
int x = 3;
List<Order> list1 = orderService.findPending();
# Python - Good
customer_email = "john@example.com"
max_retry_attempts = 3
pending_orders = order_service.find_pending()

# Bad
ce = "john@example.com"
x = 3
list1 = order_service.find_pending()

Constants

Constants use UPPER_SNAKE_CASE in both Java and Python. This convention immediately signals to the reader that the value should never change.

// Java
public static final int MAX_LOGIN_ATTEMPTS = 5;
public static final String DEFAULT_TIMEZONE = "UTC";
public static final double TAX_RATE = 0.08;
# Python
MAX_LOGIN_ATTEMPTS = 5
DEFAULT_TIMEZONE = "UTC"
TAX_RATE = 0.08

Package and Module Naming

Packages and modules organize your code into logical groups. Keep them lowercase and concise.

// Java packages - all lowercase, dot-separated, reverse domain
package com.company.userservice.repository;
package com.company.payment.gateway;

// Bad
package com.company.UserService;  // no PascalCase in packages
# Python modules - all lowercase, underscores if needed
import user_service
import payment_gateway
from data_processing import clean_data

# Bad
import UserService      # PascalCase is for classes, not modules
import data-processing  # hyphens are invalid in module names

Boolean Naming

Booleans answer yes/no questions. Prefix them with is, has, can, should, or was to make conditions read like natural English.

// Java - reads naturally in if-statements
boolean isActive = true;
boolean hasPermission = user.checkAccess(resource);
boolean canRetry = attempts < MAX_LOGIN_ATTEMPTS;
boolean shouldNotify = preference.isEmailEnabled();

// Bad - forces the reader to guess the type
boolean active = true;     // could be a String
boolean flag = true;       // meaningless
boolean check = false;     // check what?
# Python
is_active = True
has_permission = user.check_access(resource)
can_retry = attempts < MAX_LOGIN_ATTEMPTS
should_notify = preference.is_email_enabled()

# Bad
active = True
flag = True

Common Anti-Patterns to Avoid

Here are the naming mistakes that show up most often in code reviews. Avoid them deliberately.

  • Single-letter variablesa, b, x, d tell you nothing. The only exception is i, j, k in short loops.
  • Hungarian notationstrName, intAge, lstUsers. Modern IDEs and type systems make this redundant. Let the type system do its job.
  • Meaningless suffixesdata, info, stuff, object. What data? Be specific: userData is better, userProfile is best.
  • Inconsistent conventions — Mixing getUser(), fetch_order(), and retrievePayment() in the same codebase. Pick one verb and stick with it.
  • Negated booleansisNotValid leads to confusing double negatives: if (!isNotValid). Use isValid instead.
  • Overly long namesgetAllActiveUsersThatHaveNotBeenDeletedFromDatabase() is too much. Aim for clarity, not a full sentence. Something like findActiveUsers() is better.

Quick Reference

Element Java Python
Class UserAccount UserAccount
Method/Function findUserById() find_user_by_id()
Variable customerEmail customer_email
Constant MAX_RETRY MAX_RETRY
Package/Module com.company.service user_service
Boolean isActive is_active

Naming is one of those skills that separates junior developers from senior ones. It costs nothing extra to choose a good name, but the clarity it provides pays dividends every time someone reads your code — including future you. Make it a habit, and your codebase will thank you.

December 2, 2019

SOLID Principles

SOLID Principles

SOLID is an acronym for five design principles that make object-oriented code more maintainable, flexible, and scalable. These are not academic theories — they are practical guidelines that experienced engineers apply daily to keep codebases from turning into unmaintainable messes.

S — Single Responsibility Principle

A class should have one, and only one, reason to change.

Every class should own a single piece of functionality. If you need the word “and” to describe what a class does, it probably does too much. This makes classes easier to test, understand, and modify without cascading side effects.

// Good: Each class has one job
public interface UserService {
    User updateEmail(Long userId, String newEmail);
}

public interface AuthService {
    User login(String email, String password);
    User signUp(User user);
}

// Bad: One class doing everything
public class UserManager {
    public User updateEmail(...) { /* user logic */ }
    public User login(...) { /* auth logic */ }
    public void sendWelcomeEmail(...) { /* notification logic */ }
}
# Good: Separate responsibilities
class UserService:
    def update_email(self, user_id: int, new_email: str) -> User:
        ...

class AuthService:
    def login(self, email: str, password: str) -> User:
        ...

# Bad: God class
class UserManager:
    def update_email(self, ...): ...
    def login(self, ...): ...
    def send_welcome_email(self, ...): ...

A quick test: if a class changes for more than one reason (database schema change vs. business rule change), split it.

O — Open-Closed Principle

Classes should be open for extension but closed for modification.

When requirements change, you should extend behavior by adding new code — not by editing existing, tested code. Interfaces and polymorphism are your primary tools here. This prevents regressions in stable code.

# Open for extension: add new notification types without modifying existing ones
from abc import ABC, abstractmethod

class NotificationSender(ABC):
    @abstractmethod
    def send(self, user: dict, message: str) -> bool:
        pass

class EmailSender(NotificationSender):
    def send(self, user: dict, message: str) -> bool:
        # send via SMTP
        return True

class SmsSender(NotificationSender):
    def send(self, user: dict, message: str) -> bool:
        # send via Twilio
        return True

# Adding Slack notifications? Just add a new class. No existing code changes.
class SlackSender(NotificationSender):
    def send(self, user: dict, message: str) -> bool:
        # send via Slack API
        return True

Avoid if-else chains that check types — use polymorphism instead.

L — Liskov Substitution Principle

Subtypes must be substitutable for their base types without breaking the program.

If code works with a parent class, it must also work with any child class without surprises. A subclass should honor the contract of its parent — same inputs, compatible outputs, no unexpected exceptions. Violations force instanceof checks throughout the codebase, defeating the purpose of abstraction.

public interface Shape {
    double area();
}

public class Rectangle implements Shape {
    protected double width, height;

    public Rectangle(double width, double height) {
        this.width = width;
        this.height = height;
    }

    @Override
    public double area() { return width * height; }
}

public class Square implements Shape {
    private double side;

    public Square(double side) { this.side = side; }

    @Override
    public double area() { return side * side; }
}

// Any Shape works here — no special cases needed
public double totalArea(List<Shape> shapes) {
    return shapes.stream()
        .mapToDouble(Shape::area)
        .sum();
}

Notice Square is not a subclass of Rectangle. Making Square extend Rectangle violates LSP because setting width independently of height breaks Square’s invariant. Use composition or separate types instead.

I — Interface Segregation Principle

No client should be forced to depend on methods it does not use.

Prefer many small, focused interfaces over one large general-purpose interface. When a class implements an interface it does not fully need, it ends up with dead methods — empty stubs or thrown exceptions that signal a design flaw.

# Bad: Fat interface forces unnecessary implementation
class Worker(ABC):
    @abstractmethod
    def write_code(self): pass
    @abstractmethod
    def review_code(self): pass
    @abstractmethod
    def manage_team(self): pass  # Not every worker manages

# Good: Segregated interfaces
class Coder(ABC):
    @abstractmethod
    def write_code(self): pass

class Reviewer(ABC):
    @abstractmethod
    def review_code(self): pass

class Manager(ABC):
    @abstractmethod
    def manage_team(self): pass

# A tech lead implements what they actually do
class TechLead(Coder, Reviewer, Manager):
    def write_code(self): ...
    def review_code(self): ...
    def manage_team(self): ...

# A junior dev only codes
class JuniorDev(Coder):
    def write_code(self): ...

If a class has to implement a method with raise NotImplementedError, your interface is too broad.

D — Dependency Inversion Principle

High-level modules should not depend on low-level modules. Both should depend on abstractions.

Business logic should never directly instantiate its dependencies. Instead, depend on interfaces and let the framework (Spring, Django, etc.) inject the concrete implementation. This keeps your code testable and decoupled — swapping a database or external service becomes a configuration change, not a rewrite.

// Abstraction
public interface PaymentGateway {
    boolean charge(String customerId, double amount);
}

// Low-level implementation
@Service
public class StripeGateway implements PaymentGateway {
    @Override
    public boolean charge(String customerId, double amount) {
        // Stripe API call
        return true;
    }
}

// High-level module depends on abstraction, not Stripe directly
@Service
public class OrderService {
    private final PaymentGateway paymentGateway;

    @Autowired
    public OrderService(PaymentGateway paymentGateway) {
        this.paymentGateway = paymentGateway;
    }

    public boolean checkout(String customerId, double total) {
        return paymentGateway.charge(customerId, total);
    }
}

Switching from Stripe to PayPal? Create a new PayPalGateway class and update your DI configuration. OrderService never changes.

Key Takeaway

SOLID principles work together. SRP keeps classes focused. OCP lets you extend without risk. LSP ensures substitutability. ISP keeps interfaces lean. DIP decouples your architecture. Apply them pragmatically — they are guidelines, not dogma. Start with SRP and DIP; the rest follow naturally as your design matures.

December 2, 2019

Documentation

Documentation is not an afterthought — it is a core engineering deliverable. Code without documentation becomes legacy code the moment its author walks away. As a senior developer, the documentation you write falls into a handful of categories, each with a clear purpose and audience.

Code Comments: Good vs Bad

Comments should explain why, not what. If your code needs a comment to explain what it does, the code itself should be rewritten to be clearer. Good comments capture intent, trade-offs, and context that the code alone cannot convey.

Bad comments:

  • Restating the code: i++ // increment i
  • Commented-out code left for “just in case” — use version control instead
  • Outdated comments that no longer match the code (worse than no comment at all)

Good comments:

  • Explaining business logic: // Tax exempt for orders from non-profit orgs (see IRS rule 501c3)
  • Clarifying non-obvious decisions: // Using insertion sort here because n is always < 20
  • Marking known limitations: // TODO: This breaks if the input contains Unicode surrogates
  • Legal or licensing notices required by your organization

Javadoc (Java)

Javadoc is the standard for documenting public APIs in Java. Every public class, method, and interface should have a Javadoc comment. Tools generate browsable HTML documentation directly from these annotations.

/**
 * Transfers funds between two accounts within the same bank.
 * The operation is atomic — both accounts are updated in a single transaction.
 * If either account is frozen, the transfer is rejected.
 *
 * @param fromAccountId  the source account ID
 * @param toAccountId    the destination account ID
 * @param amount         the transfer amount in cents (must be positive)
 * @return the resulting transaction ID
 * @throws InsufficientFundsException if the source account balance is too low
 * @throws AccountFrozenException     if either account is frozen
 */
public String transferFunds(String fromAccountId, String toAccountId, long amount)
        throws InsufficientFundsException, AccountFrozenException {
    // implementation
}

Key rules: document parameters, return values, and exceptions. State preconditions and side effects. Keep the first sentence short — it becomes the summary in generated docs.

Docstrings (Python)

Python uses triple-quoted docstrings following the Google, NumPy, or Sphinx conventions. They serve the same purpose as Javadoc — documenting the contract of your functions and classes.

def retry_with_backoff(func, max_retries=3, base_delay=1.0):
    """Execute a function with exponential backoff on failure.

    Retries the given callable up to max_retries times, doubling
    the delay after each failed attempt. Useful for flaky network
    calls or rate-limited APIs.

    Args:
        func: A callable that takes no arguments and returns a result.
        max_retries: Maximum number of retry attempts. Defaults to 3.
        base_delay: Initial delay in seconds before first retry. Defaults to 1.0.

    Returns:
        The return value of func upon successful execution.

    Raises:
        Exception: Re-raises the last exception if all retries are exhausted.
    """
    delay = base_delay
    for attempt in range(max_retries + 1):
        try:
            return func()
        except Exception:
            if attempt == max_retries:
                raise
            time.sleep(delay)
            delay *= 2

README Files

A README is the front door to your project. Every repository should have one. A solid README answers five questions fast:

  • What does this project do? (one sentence)
  • How do I set it up locally? (prerequisites, install steps)
  • How do I run it? (commands, environment variables)
  • How do I run the tests?
  • How do I contribute? (branch strategy, PR process)

Keep it current. A README that describes a build process from two years ago will actively mislead new developers and waste hours of their time.

API Documentation

If your application exposes an API, document every endpoint with its method, path, request/response format, status codes, and authentication requirements. Use tools like Swagger/OpenAPI for REST APIs or GraphQL introspection for GraphQL. Auto-generated docs from code annotations stay in sync far better than manually maintained wikis.

Architecture Decision Records (ADRs)

An ADR captures a single architectural decision — the context, the options considered, and the rationale for the choice made. They are short (one page), numbered, and stored in the repository alongside the code, typically in a docs/adr/ directory.

ADRs solve a specific problem: six months from now, nobody will remember why you chose PostgreSQL over MongoDB, or why the service uses event sourcing instead of CRUD. ADRs preserve that reasoning so future developers (including your future self) do not revisit settled decisions.

A typical ADR structure:

  • Title: ADR-001: Use PostgreSQL for the billing service
  • Status: Accepted
  • Context: What is the problem or situation?
  • Decision: What did we decide?
  • Consequences: What are the trade-offs?

Key Principles

  • Write for the reader, not yourself. Your audience is a developer joining the team six months from now.
  • Keep docs close to the code. Documentation in the repo gets updated with the code. Documentation in a wiki does not.
  • Automate what you can. Generate API docs from annotations. Generate dependency docs from build files. Manual docs rot.
  • Delete outdated docs. Wrong documentation is worse than no documentation. If it is stale, update it or remove it.
  • Treat docs like code. Review them in PRs. Hold them to a quality standard. Refactor them when they get unwieldy.

Documentation is not overhead — it is leverage. A few hours of writing saves weeks of onboarding, debugging, and re-learning across the lifetime of a project.

December 2, 2019