Building Better Software
Practical notes for writing software that survives beyond the first git commit.
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:
- reproduce the bug
- write a test that demonstrates it
- confirm the test fails
- fix the issue
- 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:
- Make code easy to read.
- Validate everything entering your system.
- Never trust the client for security.
- Keep business rules explicit.
- Design for failure, not just success.
- Use the database to protect data integrity.
- Measure performance before optimizing.
- Test the parts that can hurt users or the business.
- Keep changes small enough to understand.
- 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
Be the first to comment.