-
Notifications
You must be signed in to change notification settings - Fork 134
SE 15 Foundational Coding Principles
Part of the Software Engineering Principles series
Beyond formal methodologies and architectural patterns lie a set of time-tested principles that guide day-to-day coding decisions. These principles are language- and framework-agnostic. They are not rigid rules — professional engineers sometimes consciously violate one for a good reason — but you should always know why you are doing so and document the trade-off.
Favour simple solutions over complex ones.
Complexity is cumulative. Every unnecessary abstraction, clever trick, or over-engineered class adds to the cognitive load of every future reader and maintainer. Simple code is easier to understand, test, debug, and extend.
In practice:
- Solve the actual problem in front of you, not the imagined harder version.
- Prefer a straightforward
if/elseover a strategy pattern when there are only two cases. - Resist the urge to make code "flexible" for scenarios that do not yet exist.
Warning sign: When you find yourself proud of how clever a piece of code is, that is usually a signal to simplify it.
Don't add functionality until it is actually needed.
YAGNI is a direct consequence of KISS applied to features. Speculative generality — code written "just in case" — is among the most common sources of unnecessary complexity and technical debt.
In practice:
- Write the smallest implementation that satisfies the current requirement.
- Do not add configuration flags, extension points, or abstract base classes for hypothetical future callers.
- If a need arises later, add the feature then — with real requirements in hand.
Why it matters: Code that is never executed still needs to be read, tested, and maintained. It also misleads future readers about what the system actually does.
Every piece of knowledge must have a single, unambiguous, authoritative representation in the system.
Duplication is not just a style problem — it is a reliability problem. When the same logic exists in two places, every bug fix or requirement change must be applied twice. One copy will eventually be forgotten.
In practice:
- Extract repeated logic into a shared method, class, or module.
- Consolidate duplicate constants into a single named value.
- Use inheritance, composition, or templates to avoid repeating structural patterns.
Caution: DRY applies to knowledge, not just text. Two functions that happen to look similar but represent different concepts should not be merged. Premature deduplication creates accidental coupling between unrelated things.
SOLID is covered in depth in SE-04: SOLID Principles. A brief summary for reference:
| Letter | Principle | Core idea |
|---|---|---|
| S | Single Responsibility | A class should have one reason to change |
| O | Open-Closed | Open for extension, closed for modification |
| L | Liskov Substitution | Subtypes must be substitutable for their base types |
| I | Interface Segregation | Many client-specific interfaces beat one general-purpose interface |
| D | Dependency Inversion | Depend on abstractions, not concretions |
Different parts of the system should handle different responsibilities.
A system that mixes UI rendering, business logic, and data access in the same place is difficult to test, reuse, or change independently. Separating these concerns makes each part smaller, more focused, and independently replaceable.
Classic layers:
- Presentation — renders output, captures input (JSF pages, REST controllers)
- Business logic — enforces rules, orchestrates workflows (EJBs, service classes)
- Data access — reads and writes persistent state (JPA repositories, DAO classes)
In practice: A JSF backing bean should not run SQL. A JPA entity should not format currency. A service method should not build HTML strings.
Hide internal details and expose only what is necessary.
Encapsulation protects the invariants of an object. When internal state is freely accessible and mutable from anywhere, any piece of code can put an object into an invalid state, and tracking down the source of corruption becomes extremely difficult.
In practice:
- Make fields
privateby default; expose only what callers legitimately need. - Provide mutators only when mutation is intentional, and validate within them.
- Return copies of mutable collections from getters when the internal collection must not be modified externally.
Hide complexity behind well-defined interfaces or APIs.
Abstraction lets you reason about a system at the right level without drowning in irrelevant detail. A well-chosen abstraction boundary means callers never need to know how something is done — only what it does.
In practice:
- Design method signatures that express intent (
processPayment(Bill b)) rather than implementation (callPostgresToInsertRowAndUpdateBalance(...)). - Use interfaces to define contracts; let implementations vary behind them.
- Regularly ask: "At what level of abstraction am I working, and is this the right level for this problem?"
Break the system into cohesive, loosely coupled modules.
A monolithic tangle of classes that call each other freely is impossible to test in isolation, difficult to assign to different team members, and painful to refactor. Modular design divides the system into units with clear boundaries and well-defined interfaces.
Benefits:
- Modules can be developed, tested, and deployed independently.
- Teams can work in parallel without constant merge conflicts.
- A module can be replaced or rewritten without touching the rest of the system.
Cohesion measures how related the responsibilities within a single module are. A highly cohesive class does one thing well.
Coupling measures how much a module depends on other modules. Loosely coupled modules communicate through stable interfaces and are unaware of each other's internals.
Target: High cohesion within modules; low coupling between them.
| High coupling | Low coupling | |
|---|---|---|
| High cohesion | Fragile but focused | ✅ Ideal |
| Low cohesion | Worst case ("god class") | Spread-out, hard to follow |
Achieving this combination usually means applying SOLID principles, Separation of Concerns, and thoughtful interface design together.
Clean code is covered in depth in SE-06: Clean Code. The core ideas:
- Meaningful names — a good name removes the need for a comment.
- Small, focused functions — each function does one thing.
- Clear intent — code should read like well-written prose.
- Minimal nesting — flatten conditionals and early-return instead of deep nesting.
- No surprises — functions should do exactly what their name suggests.
Code is read far more often than it is written. Optimise for the reader.
Leave the codebase cleaner than you found it.
Coined by Robert C. Martin, the Boy Scout Rule says that whenever you touch a file, make a small improvement — rename an unclear variable, extract a duplicated block, delete dead code, add a missing test. You are not committing to a large refactoring; you are making incremental, continuous progress.
In practice:
- Fix the one confusing name you see while implementing a feature.
- Extract the one copy-pasted block you notice while reviewing a colleague's PR.
- Delete the one commented-out block that has clearly been dead for years.
Over time, these small acts of stewardship keep a codebase healthy without ever needing a heroic "clean-up sprint."
Detect errors as early as possible.
A system that fails fast surfaces problems immediately at their source. A system that swallows errors and continues produces corrupted state that only manifests much later — far from the root cause — making diagnosis extremely difficult.
In practice:
- Validate method inputs at the point of entry; throw if they are invalid.
- Use assertions to document and check invariants that must always hold.
- In distributed systems, fail immediately on missing configuration rather than proceeding with defaults that may cause data loss.
Contrast: A null pointer exception on line 3 of a service method is far easier to diagnose than an NPE in a report that was built from data incorrectly inserted six steps earlier.
Validate inputs, handle errors gracefully, and never trust external systems.
Related to Fail Fast but broader: defensive programming assumes that callers will pass bad data, networks will fail, external APIs will return unexpected shapes, and databases will have constraint violations. It builds these assumptions into every layer.
In practice:
- Validate all input at system boundaries (user input, REST payloads, file imports, external API responses).
- Never assume a foreign key will resolve to a valid entity — handle the not-found case explicitly.
- Use timeouts on all external calls; do not let a slow dependency block your own threads indefinitely.
- Log errors with enough context to reconstruct what happened.
Distinction from over-engineering: Defensive programming applies to external boundaries. Within a single well-tested module, excessive null-checking and guard clauses add noise without safety.
Code should behave in the way most users and readers expect.
Also called the Principle of Least Surprise. A function named getTotal() that also sends an email violates this principle. A method named findById() that throws an exception when the record does not exist surprises callers who expect null or Optional.
In practice:
- Name things accurately — if a method modifies state, its name should indicate that.
- Follow the conventions of the language and framework. Diverging from standard idioms requires justification.
- Avoid hidden side effects in getters, constructors, or comparators.
- Keep the public contract of a component stable; changing observable behaviour is a breaking change regardless of whether the signature changed.
If it is repetitive, script it.
Manual processes are slow, inconsistent, and error-prone. Any task performed more than once by a human — running tests, building an artefact, deploying, formatting code, generating reports — is a candidate for automation.
Key areas:
- Testing — automated test suites run on every commit via CI
- Build — Maven, Gradle, or similar tools produce artefacts deterministically
- Deployment — GitHub Actions, Jenkins, or similar pipelines deploy automatically on merge
- Formatting/linting — static analysis catches issues before review
- Database migrations — scripted, versioned schema changes applied automatically
Why it matters: Automation is a force multiplier. A 10-minute manual deployment done 20 times a week is 3.5 hours of toil. Automated in a pipeline, it is zero.
Testing is covered in depth in SE-09: Testing Principles. The TDD core idea:
Write the test before the code.
- Write a failing test that specifies the desired behaviour.
- Write the minimum code to make the test pass.
- Refactor — clean up the code without breaking the test.
Tests are not just verification; they are executable specifications. A test suite that passes is a living document of what the system is supposed to do. It also provides a safety net that makes refactoring safe.
Agile delivery practices including CI/CD are covered in SE-14: Agile and Scrum.
In brief:
- Continuous Integration (CI): Every developer's changes are merged to a shared branch frequently (at least daily). Automated tests run on every merge to detect integration problems immediately.
- Continuous Delivery (CD): The main branch is always in a deployable state. Any version can be released at any time with minimal manual intervention.
Why it matters: Long-lived feature branches accumulate divergence that makes merging painful. Frequent integration keeps the gap small. Automated pipelines eliminate the "works on my machine" class of problems.
If it is not in version control, it does not exist.
Code is the obvious candidate, but the principle extends further:
- Configuration — application settings, server configuration, environment variables (not secrets)
- Database schemas — migration scripts, not ad-hoc ALTER statements run manually
- Infrastructure — Dockerfiles, Kubernetes manifests, Terraform definitions (Infrastructure as Code)
- Documentation — architecture decision records, API docs, runbooks
Version control provides a complete audit trail, enables rollback, and allows the team to collaborate on any of these artefacts using the same workflows used for code.
Prefer data structures that cannot be modified after creation.
Mutable shared state is the root cause of most concurrency bugs. An immutable object can be safely shared across threads, passed freely to untrusted code, and used as a dictionary key without fear of the underlying value changing.
In practice (Java):
- Declare fields
finalwhen they should not change after construction. - Return unmodifiable views from collection getters:
Collections.unmodifiableList(...). - Prefer value objects (no setters, all state set in constructor) for domain concepts like
Money,Period,PatientId. - Use
String,LocalDate,BigDecimal— all immutable by design.
Trade-off: Immutability sometimes requires more object creation. Profile before deciding this is a performance problem.
You cannot fix what you cannot see.
A system deployed to production with no observability is a black box. When something goes wrong — and it will — the only tool available is guesswork.
Three pillars:
| Pillar | What it answers | Examples |
|---|---|---|
| Logs | What happened, in what order | Application logs, audit trails, error stacks |
| Metrics | How healthy is the system right now | Request rate, response time, error rate, queue depth |
| Traces | How did this request flow through the system | Distributed tracing across services |
In practice:
- Log at the right level: DEBUG for development detail, INFO for significant events, WARN for recoverable problems, ERROR for failures requiring attention.
- Include enough context in log messages to reconstruct the user's action (user ID, operation, relevant IDs — never passwords or PII).
- Instrument key operations from day one; retrofitting observability into a complex system is painful.
Security is covered in depth in SE-12: Security Principles.
The foundational idea: security is not a feature added at the end of development. It is a property designed into every layer from the beginning.
Core habits:
- Validate and sanitise all input at every system boundary.
- Apply the principle of least privilege — grant the minimum access a component needs, no more.
- Never store passwords in plain text; never log credentials, PII, or secrets.
- Keep dependencies up to date; known vulnerabilities in libraries are a primary attack vector.
- Treat threat modelling as part of design, not an afterthought.
Optimise only when necessary, and with measurements.
Premature optimisation — writing complex, hard-to-read code to solve a performance problem that has not been measured — is one of the most common sources of unnecessary complexity. As Donald Knuth observed: "Premature optimisation is the root of all evil."
The right process:
- Make it work correctly.
- Make it readable and maintainable.
- Measure — identify the actual bottleneck with profiling data.
- Optimise the bottleneck only.
However: Performance considerations that are structurally baked in (choosing the right algorithm, avoiding N+1 queries, using appropriate indexes) belong at design time. These are not premature — they are correct design.
Design for concurrency when needed; understand what happens when two things happen at once.
Modern systems handle many requests simultaneously. Code that is correct when called by a single thread may produce race conditions, corrupted state, or deadlocks when called concurrently.
Core concepts:
- Race condition — the result depends on the relative timing of two concurrent operations.
- Deadlock — two threads each wait for a resource held by the other; neither can proceed.
- Thread safety — a component is thread-safe if it behaves correctly when called from multiple threads simultaneously.
In practice:
- Prefer stateless components (EJBs, CDI
@RequestScoped) — no shared mutable state means no race conditions. - Use synchronisation primitives (
synchronized,Lock,Atomic*) only when sharing state is unavoidable, and keep the critical section as small as possible. - Use database transactions to enforce atomicity at the persistence layer.
- Understand that
@SessionScopedand@ApplicationScopedbeans may be accessed by multiple threads concurrently.
Good code is self-documenting, but some things still need explanation.
The goal is not to eliminate documentation but to put each type of knowledge in the right place:
| What to document | Where |
|---|---|
| What a public API does | Javadoc on public methods |
| Why a non-obvious decision was made | Inline comment at the point of decision |
| Architecture and cross-cutting concerns | Architecture Decision Records (ADRs) |
| How to set up, build, and run | README / developer guide |
| Business workflows | Wiki / user documentation |
What not to document: What the code does — that is the code's job. A comment that says // increment counter above count++ adds noise, not knowledge.
Keep docs up to date: Stale documentation is worse than none — it actively misleads. Update docs as part of every change that affects them.
Shorten the cycle: code → test → deploy → monitor → learn.
Software development improves through rapid feedback. The longer it takes to discover that something is wrong, the more expensive it is to fix.
Types of feedback loops (fastest to slowest):
| Loop | Trigger | Typical delay |
|---|---|---|
| Static analysis / linting | On save in IDE | Seconds |
| Unit tests | On every build | Seconds to minutes |
| Integration / CI tests | On every commit | Minutes |
| Code review | Before merge | Hours to days |
| Staging environment testing | Before release | Hours to days |
| Production monitoring / alerting | After deploy | Minutes to hours |
| User feedback | After release | Days to weeks |
In practice: Invest in fast inner loops. A test suite that takes 45 minutes discourages developers from running it. A CI pipeline that fails clearly in 5 minutes prevents problems from reaching production.
These sayings capture the spirit of the principles above:
"Make it work, make it right, make it fast." (in that order) — Kent Beck
Write something that is correct first. Clean it up second. Optimise only if measured performance requires it.
"If it hurts, do it more often." — Jez Humble
Painful activities like deployments, merges, or production rollouts hurt because they are infrequent. Doing them more often — and automating them — forces you to remove the pain rather than tolerate it.
"Code is a liability; the less you have, the better."
Every line of code must be read, tested, debugged, and maintained. The best code is code you did not need to write. Solve the problem with the minimum code that is correct.
"There are two hard things in computer science: cache invalidation, naming things, and off-by-one errors." — Phil Karlton (adapted)
A lighthearted reminder that naming is genuinely difficult and deserves care.
These principles are not independent rules — they form a coherent philosophy:
- YAGNI prevents the features that would violate KISS.
- DRY applied carelessly violates Separation of Concerns — deduplicate knowledge, not accidental similarity.
- High Cohesion is what SOLID's Single Responsibility Principle looks like at the module level.
- Fail Fast and Defensive Programming work together: validate at the boundary, fail immediately, log with context.
- Automation, CI/CD, and short Feedback Loops are the same idea at different scales.
- Immutability is the architectural expression of Encapsulation applied to data over time.
No single principle is absolute. A senior engineer applies them in context, knows when to violate one deliberately, and can articulate why.
Previous: SE-14: Agile and Scrum