Building Better Software

Practical notes for writing software that survives beyond the first git commit.

Building Better Software

Building Better Software

Practical notes for writing software that survives beyond the first git commit.

Good code is not code that looks clever.

Good code is code that another developer—or yourself six months later—can understand, debug, change, test, and safely deploy.

This page collects practical coding principles that are useful across languages, frameworks, and project sizes.



1. Optimize for Readability First

Code is read far more often than it is written.

Prefer this:

const eligibleUsers = users.filter(
  (user) => user.isActive && user.emailVerified,
);

over this:

const u = users.filter((x) => x.a && x.ev);

Shorter code is not automatically better code.

A useful rule:

Write for the next developer, not for the compiler.


2. Name Things by Meaning, Not Type

Avoid names that describe only what the variable technically is.

Bad:

const data = await fetchUsers()
const list = data.filter(...)
const value = list.length

Better:

const users = await fetchUsers();
const activeUsers = users.filter((user) => user.isActive);
const activeUserCount = activeUsers.length;

Good names reduce the need for comments.


3. Boolean Names Should Read Like Questions

Prefer:

isAuthenticated;
hasPermission;
canEdit;
shouldRefresh;
wasSuccessful;

Avoid:

auth;
permission;
editable;
status;
flag;

This makes conditions easier to read:

if (user.isAuthenticated && user.canEdit) {
  showEditor();
}

4. Functions Should Do One Clear Thing

A function becomes difficult to maintain when it:

  • validates data
  • queries the database
  • modifies state
  • sends email
  • logs analytics
  • formats the response

all at once.

Instead of:

async function registerUser(data) {
  // validate
  // create user
  // send email
  // create profile
  // log event
  // return response
}

separate responsibilities:

validateRegistration(data);

const user = await createUser(data);

await createDefaultProfile(user);
await sendWelcomeEmail(user);
await trackRegistration(user);

The goal is not to create hundreds of tiny functions.

The goal is to make each function understandable without reading fifty unrelated lines.


5. Prefer Early Returns

Deep nesting makes code harder to scan.

Instead of:

if (user) {
  if (user.isActive) {
    if (user.hasPermission) {
      updateProfile();
    }
  }
}

prefer:

if (!user) return;
if (!user.isActive) return;
if (!user.hasPermission) return;

updateProfile();

This is often called a guard clause.

It keeps the happy path visible.


6. Validate at the Boundary

Never assume input is valid.

Validate data when it enters your system:

  • HTTP requests
  • forms
  • API payloads
  • webhooks
  • CLI arguments
  • environment variables
  • uploaded files
  • third-party responses

Example:

const schema = z.object({
  email: z.string().email(),
  age: z.number().int().min(18),
});

const input = schema.parse(request.body);

Once validated, internal code can operate with stronger assumptions.

Validate once at the edge instead of repeatedly guessing inside the application.


7. Never Trust the Frontend for Authorization

Hiding a button is not security.

This:

<button v-if="user.isAdmin">
  Delete User
</button>

improves UX.

It does not prevent someone from manually calling:

DELETE /api/users/123

Authorization must always be enforced on the server.

Frontend:

Should the user see this action?

Backend:

Is the user actually allowed to perform this action?

Both are necessary.


8. Authentication and Authorization Are Different

Authentication answers:

Who are you?

Authorization answers:

What are you allowed to do?

A logged-in user is not automatically allowed to access every resource.

Example:

$user = auth()->user();

abort_unless(
    $user->can('update', $post),
    403
);

Never confuse successful login with permission.


9. Never Store Plaintext Passwords

Passwords should be hashed using a password-specific algorithm.

Use framework-provided implementations such as:

  • Argon2
  • bcrypt

Never:

password = sha256(password);

and definitely never:

password = "mypassword123"

inside the database.

Also avoid implementing password cryptography yourself.


10. Secrets Do Not Belong in Source Code

Never commit:

DATABASE_PASSWORD=...
API_SECRET=...
PRIVATE_KEY=...
STRIPE_SECRET=...

Use environment variables or a dedicated secrets manager.

Before pushing code:

git diff
git status

Check what you are actually committing.

If a secret has already been committed, deleting it from the latest commit is not enough.

Assume it is compromised and rotate it.


11. Treat External APIs as Unreliable

Even excellent APIs can:

  • timeout
  • rate-limit
  • return malformed data
  • change behaviour
  • temporarily fail

Avoid:

const response = await fetch(API_URL);
const data = await response.json();

return data.results[0].name;

Prefer defensive handling:

const response = await fetch(API_URL);

if (!response.ok) {
  throw new ExternalApiError(response.status);
}

const data = await response.json();

if (!data.results?.length) {
  return null;
}

return data.results[0].name;

Your application should fail gracefully when dependencies fail.


12. Add Timeouts to Network Requests

A network request should not be allowed to wait forever.

Example:

const controller = new AbortController();

const timeout = setTimeout(() => {
  controller.abort();
}, 5000);

try {
  const response = await fetch(url, {
    signal: controller.signal,
  });

  return response;
} finally {
  clearTimeout(timeout);
}

Timeout behaviour should be deliberate.


13. Retry Carefully

Retries help with temporary failures.

But blindly retrying everything can make incidents worse.

Good candidates:

  • transient network errors
  • temporary service unavailable responses
  • rate-limited requests with backoff

Bad candidates:

POST /charge-credit-card

unless the operation supports idempotency.

A useful retry pattern:

Request
 ↓
Failure
 ↓
Wait 1 second
 ↓
Retry
 ↓
Wait 2 seconds
 ↓
Retry
 ↓
Wait 4 seconds

This is exponential backoff.


14. Make Important Operations Idempotent

An idempotent operation can safely be repeated without creating duplicate effects.

Imagine a payment request times out.

Did the payment fail?

Or did the payment succeed but the response disappear?

Without idempotency, retrying could charge the customer twice.

Example:

POST /payments
Idempotency-Key: order_928273

The server remembers the key and prevents duplicate processing.

Useful for:

  • payments
  • order creation
  • webhook processing
  • email jobs
  • background workers

15. Database Constraints Are Part of Your Application

Do not rely entirely on application code.

If an email must be unique:

CREATE UNIQUE INDEX users_email_unique
ON users(email);

Not just:

if (!User::where('email', $email)->exists()) {
    User::create(...);
}

Two requests could arrive simultaneously.

Database constraints protect against race conditions that application-level checks may miss.


16. Use Transactions for Related Database Changes

If multiple database operations must succeed together, use a transaction.

Example:

DB::transaction(function () use ($order) {
    $payment = Payment::create(...);

    $order->update([
        'payment_id' => $payment->id,
        'status' => 'paid',
    ]);

    Inventory::reserve($order);
});

If one operation fails, the others should not remain half-completed.


17. Avoid the N+1 Query Problem

Suppose you retrieve 100 posts:

$posts = Post::all();

Then:

foreach ($posts as $post) {
    echo $post->author->name;
}

You may accidentally execute:

1 query for posts
100 queries for authors

Instead:

$posts = Post::with('author')->get();

Result:

1 query for posts
1 query for authors

Learn how your ORM actually talks to the database.


18. Index Columns You Search Frequently

An index can dramatically improve query performance.

Common candidates:

email
username
created_at
foreign keys
status
slug

But do not index everything.

Indexes:

  • consume storage
  • make writes slightly more expensive
  • need maintenance

Always check the actual query pattern.


19. Pagination Is Not Optional at Scale

Avoid:

SELECT * FROM users;

when your table could eventually contain millions of rows.

Use pagination:

GET /users?page=2&per_page=50

For large datasets, cursor pagination may be better:

GET /users?after=usr_82hd91

Never assume today's small dataset will stay small.


20. Avoid SELECT *

Retrieve only what you need.

Instead of:

SELECT *
FROM users;

prefer:

SELECT id, name, avatar_url
FROM users;

Benefits include:

  • smaller payloads
  • reduced memory usage
  • clearer dependencies
  • less accidental exposure of sensitive fields

21. Keep API Responses Predictable

Avoid returning completely different structures for similar endpoints.

Better:

{
  "data": {
    "id": 42,
    "name": "Example"
  }
}

Errors:

{
  "error": {
    "code": "VALIDATION_FAILED",
    "message": "The request contains invalid data.",
    "fields": {
      "email": ["A valid email address is required."]
    }
  }
}

Consistency makes frontend integration much easier.


22. Use HTTP Status Codes Properly

Useful common statuses:

200 OK
201 Created
204 No Content

400 Bad Request
401 Unauthorized
403 Forbidden
404 Not Found
409 Conflict
422 Unprocessable Content
429 Too Many Requests

500 Internal Server Error
502 Bad Gateway
503 Service Unavailable

Don't return:

200 OK

with:

{
  "success": false,
  "error": "Unauthorized"
}

when the actual response should be 401 or 403.


23. Do Not Leak Internal Errors

Bad production response:

{
  "error": "SQLSTATE[42S02]: Base table or view not found...",
  "file": "/var/www/app/Services/UserService.php",
  "line": 82
}

Better:

{
  "error": {
    "code": "INTERNAL_ERROR",
    "message": "Something went wrong."
  }
}

Log detailed information internally.

Expose safe information externally.


24. Logs Should Help Answer Questions

Bad:

console.log("error");

Better:

logger.error("Payment processing failed", {
  orderId,
  paymentProvider,
  errorCode,
  requestId,
});

Useful logs answer:

What happened?
When?
For which request?
For which user/order/resource?
Which service failed?

Avoid logging:

  • passwords
  • access tokens
  • private keys
  • sensitive personal information

25. Use Structured Logging

Instead of:

Payment failed for order 123 because timeout

prefer structured data:

{
  "level": "error",
  "event": "payment.failed",
  "order_id": 123,
  "provider": "example",
  "reason": "timeout"
}

Structured logs are much easier to search, filter, and analyse.


26. Generate Request IDs

When a request moves through several services:

Browser
   ↓
API
   ↓
Payment Service
   ↓
Database

a request ID helps correlate logs:

request_id = req_8D2MQL93

Include the same ID across related operations.

When something breaks, you can trace the full journey.


27. Avoid Catching Errors You Cannot Handle

Bad:

try {
  await processPayment();
} catch (error) {
  console.log(error);
}

The error disappears and the application continues in an unknown state.

Better:

try {
  await processPayment();
} catch (error) {
  logger.error("Payment failed", { error });
  throw error;
}

Catch errors when you can:

  • recover
  • translate them
  • retry them
  • add useful context
  • return a meaningful response

Otherwise, allow them to propagate.


28. Prefer Explicit State

Avoid ambiguous status fields:

status = 1
status = 2
status = 3

Prefer:

pending
processing
completed
failed
cancelled

Even better, define them centrally:

enum OrderStatus {
  Pending = "pending",
  Processing = "processing",
  Completed = "completed",
  Failed = "failed",
}

Future-you should not have to remember what 3 means.


29. Model State Transitions Deliberately

Not every state should be reachable from every other state.

Example:

pending
   ↓
processing
   ↓
completed

A completed payment probably should not transition back to:

pending

Define allowed transitions.

This prevents impossible states from slowly entering your database.


30. Cache Only What You Understand

Caching can make applications dramatically faster.

It can also create extremely confusing bugs.

Before caching something, answer:

What is being cached?
How long?
What invalidates it?
What happens if the cache is stale?
What happens if the cache is unavailable?

Never add caching merely because:

"Redis makes things faster."

Measure first.


31. Measure Before Optimizing

Don't rewrite clean code because you think one loop looks slow.

Use:

  • profilers
  • database query analysis
  • request timings
  • memory measurements
  • application metrics

A slow application is often caused by:

database queries
network requests
large payloads
disk I/O
unnecessary rendering

not whether one function uses .map() instead of a for loop.


32. Avoid Premature Abstraction

You write something twice and immediately create:

AbstractGenericUniversalDataServiceFactory

Now the abstraction is harder to understand than the duplication.

A useful rule:

Duplicate a little before abstracting too early.

Wait until the repeated pattern becomes clear.

Then extract the correct abstraction.


33. DRY Does Not Mean “Never Duplicate Code”

DRY — Don't Repeat Yourself refers more importantly to duplicated knowledge, not every repeated line.

Two similar-looking functions may represent completely different business concepts.

Forcing them into one abstraction can create worse coupling.

Sometimes:

two simple functions

are better than:

one extremely configurable function

34. Prefer Composition Over Giant Components

Frontend components should not contain:

authentication
API fetching
business rules
modal state
form validation
data formatting
analytics
rendering

all inside one 900-line file.

Separate concerns into:

components
composables / hooks
services
validators
utilities
stores

But don't split every ten lines into a separate file either.

Aim for understandable boundaries.


35. Keep Business Logic Out of UI Components

Bad:

<button
  onClick={() => {
    if (
      order.total > 500 &&
      customer.level === 'gold' &&
      !order.discount
    ) {
      ...
    }
  }}
>

Better:

const canApplyDiscount = discountService.canApply({
  order,
  customer,
});

Then:

<button disabled={!canApplyDiscount}>Apply Discount</button>

Business rules should be testable without rendering the UI.


36. Do Not Store Derived State Unless Necessary

Suppose you already have:

firstName;
lastName;

Avoid unnecessarily storing:

fullName;

Instead:

const fullName = `${firstName} ${lastName}`;

Every duplicated state introduces another opportunity for data to become inconsistent.


37. Make Loading, Empty, Error, and Success States Explicit

A UI is not just:

Loading
↓
Success

Real applications have:

idle
loading
success
empty
error
retrying

Design all of them.

Example:

if (isLoading) return <LoadingState />
if (error) return <ErrorState />
if (!users.length) return <EmptyState />

return <UserList users={users} />

Empty states are product states, not bugs.


38. Debounce Expensive User Input

Do not send an API request for every keystroke in a search box.

User types:

m
mo
moo
moon
moonl
moonli
moonlight

Without debounce:

8 requests

With debounce:

1 request after typing pauses

Typical debounce windows:

200–500 ms

depending on the experience.


39. Protect Against Double Submission

Users double-click buttons.

Networks lag.

Mobile devices behave unpredictably.

Disable submission while processing:

<button disabled={isSubmitting}>{isSubmitting ? "Saving…" : "Save"}</button>

But backend protection should still exist where duplication matters.

Frontend prevention improves UX.

Backend idempotency protects data.


40. Comments Should Explain Why

Bad:

// Increment count
count++;

The code already says that.

Useful:

// Provider occasionally sends duplicate webhook events,
// so processed event IDs are checked before handling.
if (await eventExists(event.id)) {
  return;
}

A good comment explains:

why
trade-offs
unexpected constraints
external behaviour
business rules

not obvious syntax.


41. Delete Dead Code

Don't leave this everywhere:

// const oldHandler = ...
// TODO maybe use this again

Git already remembers deleted code.

Dead code increases cognitive load.

If it is not used:

Delete it.

You can recover it from version history if needed.


42. A TODO Needs Context

Bad:

// TODO fix

Better:

// TODO: Replace temporary polling with webhook delivery
// once provider webhook verification is implemented.

Even better, link it to your issue tracker when appropriate.


43. Keep Pull Requests Focused

Avoid combining:

new checkout flow
database refactor
formatter changes
dependency upgrade
navbar redesign

into one pull request.

Smaller PRs are:

  • easier to review
  • easier to test
  • easier to revert
  • easier to understand

A good PR should have one coherent purpose.


44. Commit Messages Should Explain Intent

Avoid:

fix
update
changes
final
final2
really-final

Prefer:

fix(auth): prevent expired token refresh loop
feat(calendar): support recurring events
refactor(api): extract response transformer

You should be able to scan git log and understand how the project evolved.


45. Never Mix Formatting With Functional Changes

If you need to reformat 100 files:

do it separately.

Otherwise the meaningful change becomes buried inside thousands of irrelevant diff lines.

Good:

PR #1 — Run formatter

PR #2 — Refactor payment service

Reviews become dramatically easier.


46. Test Behaviour, Not Implementation Details

Fragile test:

expect internalMethodA to be called exactly twice

Better test:

Given an expired session,
when the user requests a protected resource,
they should receive an authentication error.

Your implementation can now change without unnecessarily breaking the test.


47. Test the Dangerous Paths First

You do not need 100% coverage before your tests become valuable.

Prioritize:

authentication
authorization
payments
data deletion
financial calculations
critical business rules
permissions
webhooks
account recovery

A button colour needs less testing than a payment transaction.


48. A Useful Test Structure

Use:

Arrange
Act
Assert

Example:

// Arrange
const user = createUser({
  balance: 100,
});

// Act
const result = withdraw(user, 40);

// Assert
expect(result.balance).toBe(60);

Tests should communicate expected behaviour.


49. Reproduce Bugs Before Fixing Them

When possible:

  1. reproduce the bug
  2. write a test that demonstrates it
  3. confirm the test fails
  4. fix the issue
  5. confirm the test passes

Now the bug is less likely to return later.


50. Debug by Reducing the Problem

When something fails, don't randomly change code.

Reduce the problem.

Ask:

Is the frontend sending the request?
Does the API receive it?
Is validation passing?
Is the database query running?
Is the response correct?
Does the UI receive the response?

Move through the system one boundary at a time.

This is faster than guessing.


A Practical Debugging Flow

When something breaks:

1. Read the actual error
       ↓
2. Reproduce consistently
       ↓
3. Identify the smallest failing layer
       ↓
4. Inspect inputs and outputs
       ↓
5. Check recent changes
       ↓
6. Form one hypothesis
       ↓
7. Test the hypothesis
       ↓
8. Fix the root cause
       ↓
9. Add regression protection

Avoid changing five unrelated things simultaneously.

Otherwise you may fix the bug without knowing why.


Questions to Ask Before Writing Code

Before implementing a feature, ask:

Product

  • What problem are we solving?
  • Who actually needs this?
  • What is the smallest useful version?
  • What happens when there is no data?
  • What happens when it fails?

Data

  • What data needs to exist?
  • Which system owns it?
  • What must be unique?
  • What can be nullable?
  • What needs an audit trail?

Security

  • Who can perform this action?
  • Can a user access another user's resource?
  • Is sensitive data involved?
  • Can this endpoint be abused?
  • Do we need rate limiting?

Reliability

  • What if the request executes twice?
  • What if an external service goes down?
  • Can this operation safely retry?
  • Does it require a transaction?

Maintenance

  • How will another developer understand this?
  • How will we test it?
  • How will we debug it in production?
  • How will this evolve later?

Ten minutes spent answering these questions can save hours of rewriting.


Code Review Checklist

Before merging:

  • Does the code solve the actual requirement?
  • Are names clear?
  • Are functions reasonably focused?
  • Is duplicated business logic avoided?
  • Are inputs validated?
  • Is authorization enforced server-side?
  • Are secrets protected?
  • Are errors handled intentionally?
  • Are database operations safe?
  • Could this create duplicate records?
  • Are database queries efficient?
  • Are loading and error states handled?
  • Are logs useful without exposing sensitive information?
  • Are critical behaviours tested?
  • Is there unnecessary complexity?
  • Can another developer understand the change without explanation?

Before Production Checklist

  • Debug mode disabled
  • Production secrets configured
  • Development credentials removed
  • Database migrations tested
  • Backups configured
  • HTTPS enforced
  • Authentication tested
  • Authorization tested
  • Rate limiting configured where appropriate
  • Error pages configured
  • Sensitive errors hidden
  • Logging enabled
  • Monitoring configured
  • Health checks available
  • Critical jobs monitored
  • External API failures handled
  • Database indexes reviewed
  • CORS configuration reviewed
  • File upload restrictions reviewed
  • Dependency vulnerabilities reviewed

Avoid These Patterns

Clever One-Liners

If this:

const x =
  a?.b
    ?.filter((c) => c.d)
    ?.map((e) => e.f)
    .reduce((g, h) => g + h, 0) ?? 0;

takes thirty seconds to understand, split it.


Giant Utility Files

utils.ts
helpers.php
common.js
misc.dart

often become dumping grounds.

Organize utilities around domains instead:

currency.ts
dates.ts
permissions.ts
validation.ts

God Classes

Avoid classes that handle:

users
payments
notifications
reports
permissions
analytics
exports

A class with twenty unrelated responsibilities usually indicates missing boundaries.


Magic Numbers

Bad:

if (attempts > 5)

Better:

const MAX_LOGIN_ATTEMPTS = 5

if (attempts > MAX_LOGIN_ATTEMPTS)

Now the number has meaning.


Copy-Paste Programming

Copy-paste can be useful while exploring.

But once duplicated behaviour represents the same business rule, centralize it.

Otherwise fixing one copy does not fix the others.


The Rule of Simple Software

When deciding between two solutions, prefer the one with:

  • fewer moving parts
  • clearer behaviour
  • fewer hidden assumptions
  • easier debugging
  • easier testing
  • easier replacement

unless the more complex solution solves a real requirement.

Complexity must earn its place.


Final Principles

If you remember nothing else, remember these:

  1. Make code easy to read.
  2. Validate everything entering your system.
  3. Never trust the client for security.
  4. Keep business rules explicit.
  5. Design for failure, not just success.
  6. Use the database to protect data integrity.
  7. Measure performance before optimizing.
  8. Test the parts that can hurt users or the business.
  9. Keep changes small enough to understand.
  10. Prefer boring, predictable code over clever code.

One Last Rule

If you cannot explain why the code exists, you probably should not ship it.

Software does not become maintainable because it uses the newest framework.

It becomes maintainable when its decisions are understandable, its boundaries are clear, its failures are predictable, and changing one thing does not unexpectedly destroy five others.

Write code for production. Write code for maintenance. Write code for whoever has to debug it at 3 AM.

Discussion

0 comments

Protected by Akismet

Be the first to comment.