Prefer constructor injection over field injection
Inject dependencies through the constructor so they are explicit, mandatory, and final, which makes the class immutable and trivially testable without a container. Field injection hides dependencies, allows objects to exist in a partially constructed state, and forces reflection in tests.
- Do:
public OrderService(OrderRepository repo, Clock clock) { this.repo = repo; this.clock = clock; } - Don't: annotate private mutable fields with
@Autowiredand provide no constructor.
Make domain objects immutable by default
Model values and DTOs as immutable types (Java records, final fields, frozen structures) so shared state cannot be corrupted and objects are thread-safe. Mutate by producing a new instance rather than changing the original.
- Do:
record Money(BigDecimal amount, Currency currency) {}and return a newMoneyfromadd. - Don't: expose setters on a value object, letting any caller break its invariants after construction.
Keep the domain layer free of framework dependencies
Apply the Clean Architecture dependency rule: business rules must not import web, persistence, or framework types. Depend on interfaces the domain defines, and implement them in outer layers, so core logic is testable without spinning up Spring or a database.
- Do: define a
OrderRepositoryinterface in the domain and a JPA implementation in infrastructure. - Don't: annotate a domain entity with
@Entityand@Table, coupling business rules to Hibernate.
Validate input at system boundaries
Treat all external data (HTTP requests, message payloads, file content, third-party responses) as untrusted and validate it the moment it enters the system, failing fast with clear errors. Inner layers can then assume valid data.
- Do: validate a request DTO with Bean Validation (
@NotBlank,@Email) in the controller before mapping to the domain. - Don't: pass raw request strings deep into business logic and discover they are malformed three layers down.
Never swallow exceptions silently
An empty catch block hides failures and produces systems that fail invisibly. Either handle the error meaningfully, log it with enough context to diagnose, or rethrow it wrapped in a domain-meaningful exception.
- Do:
catch (IOException e) { throw new ReportGenerationException("failed to write report " + id, e); } - Don't:
catch (Exception e) {}- the failure vanishes and debugging becomes guesswork.
Use parameterised queries for all dynamic SQL
Send user input as bound parameters, never concatenated into the query string, to make SQL injection structurally impossible. This holds for raw JDBC, query builders, and native ORM queries alike.
- Do:
SELECT * FROM users WHERE email = ?with the value bound as a parameter. - Don't:
"SELECT * FROM users WHERE email = '" + email + "'"- one crafted input compromises the database.
Write the test first for non-trivial logic
Drive design from the consumer's perspective with TDD: write a failing test, make it pass with the simplest code, then refactor. The test clarifies the requirement, and the resulting suite is a regression net that makes future change safe.
- Do: write a failing test for the edge case (empty cart, expired token) before implementing the handler.
- Don't: write all the production code first and bolt on tests that merely confirm whatever it happens to do.
Make retries safe with idempotency
In any system that can retry a request (clients, message queues, gateways), ensure the operation is idempotent so a duplicate has no extra effect. For non-idempotent operations like resource creation, use an idempotency key the server deduplicates on.
- Do: accept an
Idempotency-Keyheader on POST and return the original result for a repeated key. - Don't: process every incoming POST blindly, so a client timeout-and-retry charges the customer twice.
Protect cross-service calls with timeouts and a circuit breaker
A remote call without a timeout can block a thread forever and cascade into total failure under load. Bound every call with a timeout, add bounded retries with backoff, and wrap unreliable dependencies in a circuit breaker that fails fast when they are clearly unhealthy.
- Do: configure a Resilience4j circuit breaker plus a per-call timeout on the WebClient to the payment service.
- Don't: call a downstream API with default infinite timeouts and no fallback, letting one slow dependency exhaust the thread pool.
Keep functions small and single-purpose
A function should do one thing at one level of abstraction, ideally well under fifty lines and without deep nesting. Small, well-named functions are self-documenting, easier to test, and easier to reuse than long procedures mixing concerns.
- Do: extract validation, mapping, and persistence into named helper methods called by a short orchestrating method.
- Don't: pack parsing, business rules, logging, and I/O into one hundred-line method with five levels of
ifnesting.
Don't repeat knowledge, but tolerate incidental duplication
Apply DRY to duplicated knowledge - a business rule or constant that must change in lockstep belongs in one place. Resist the urge to merge code that merely looks similar but changes for different reasons, since a premature abstraction couples unrelated concepts.
- Do: extract a single VAT-rate constant referenced everywhere the rate is applied.
- Don't: force two coincidentally similar functions behind one parameterised helper that grows flags for every difference.
Practise continuous integration on a releasable mainline
Merge small changes into the mainline frequently, with every merge triggering an automated build and test run, and keep the mainline always releasable. Short-lived branches and fast feedback prevent the painful big-bang integrations that long-lived branches cause.
- Do: integrate at least daily behind a feature flag, with CI gating the merge on green tests.
- Don't: develop a feature on a branch for three weeks and attempt a massive merge with unresolved conflicts.
Instrument code for observability from the start
Build in structured logs, metrics, and distributed traces so you can diagnose production behaviour, including failures you did not anticipate. Correlate requests with a trace or correlation ID and log meaningful context, not opaque "error occurred" lines.
- Do: emit a structured log with a correlation ID, log metrics for latency and error rate, and trace cross-service calls.
- Don't: rely on plain
System.out.printlnlines with no context, then guess what happened in production.
Manage configuration through the environment, not code
Following the Twelve-Factor approach, keep configuration that varies between deploys (credentials, URLs, feature toggles) in the environment, never hardcoded or committed. This lets the same artefact run in every environment and keeps secrets out of source control.
- Do: read the database URL and API keys from environment variables, validated for presence at startup.
- Don't: commit a config file with production passwords, or branch on a hardcoded
if (env == "prod")in business code.