25 September 2026

Fuzzy contracts are eating away your developer skills.

Older, widely used code rarely treats the Single Responsibility Principle (SRP) as a strict contract. Each method does roughly one thing, but the edge cases (nulls, mutability, memory, fallbacks) are decided case by case:
  • Date / SimpleDateFormat are mutable and not thread-safe.
  • List.of(...).contains(null) throws NPE.
  • Collections.unmodifiableList is only a view - changes to the original list still show through.
  • Integer.parseInt(null) throws NumberFormatException instead of NullPointerException.
  • Arrays.sort(a, null) silently falls back to natural order.
Each exception is small, but together they mean that names can't be trusted, so every piece of documentation has to be read. Documentation stops being clarification and becomes a list of exceptions you have to remember. Newer APIs are somewhat better, but lost trust is hard to rebuild. The old warning "always read the documentation" was never about good practice - it was about code whose names could not be trusted.
Current code is easy to write and read as long as it fits conventions, but whenever something goes beyond them, we see more errors and less clear code.
Conventions become harmful when they collapse multiple domains into one while only partially handling each of them. Later, developers mistake that partial pattern for the correct model; with time and popularity, it's taken as proper design.
CSRD - Composable Single Responsibility Domains
Overall rules:
  • Unambiguity - API directly correlates to responsibility.
  • One domain & strict domain rules.
  • Refraining from taking on others' responsibilities or assumptions.
  • Composability - the final effect should be achievable with composition instead of being imprinted into each function.
  • The contract cannot be abandoned - parts of the contract cannot be omitted just because they are hard to implement or rare.
  • Minimal expression - do not introduce an additional domain when the current one is self-explanatory.
  • Doing no harm is merely the absolute minimum; the goal is to provide help.
  • Don't make things up.
  • Limitation lifting - when a domain lifts a limitation, it should be reflected in the implementation (direct to static) (it's unreasonable to push the limitation all the way up).
  • Domain hierarchy has to end somewhere - an enclosing domain may explicitly limit which domains it supports (e.g. a demo, a single security framework, a single type of DB).
  • Changing a condition vs changing structure.
Unambiguity - API directly correlates to responsibility.
There are infinite ways to make developers suffer by making decisions that are not ours to make - even worse when we do it silently. A list whose size can only grow. A converter that removes spaces. A getter that exposes everything inside. A method that invokes autoboxing when types change.
public static <T extends Comparable<? super T>> T[] sort(T[] a) {} public static <T extends Comparable<? super T>> T[] sort(T[] a, int fromIndex, int toIndexExclusive) {} public static <T> T[] sort(T[] a, int fromIndex, int toIndexExclusive, Comparator<? super T> c) {}

T[] a
  • must not assume mutability
  • must not assume unlimited memory
  • must not assume a nullability policy
<T extends Comparable<? super T>>
  • uses Comparable contract
int fromIndex, int toIndexExclusive
  • contract is applied to the given range
  • must not assume external traversal logic
Comparator<? super T> c
  • must not switch to natural order
  • must not assume a comparator can always be provided
Additional Explanations:
  • must not assume mutability - the possibility of sorting does not indicate whether the input may be modified - so, just in case, a defensive copy needs to be made. The cost of copying is intentional: compromises on safety never end, so the default must be safe, and speed must be requested explicitly.
  • must not assume unlimited memory - we can expect cloning, so 2x array memory is reasonable, but 100x+ is not something we are allowed to do if it can be avoided. For this reason, sorting an infinite number of empty arrays should not allocate memory.
  • must not assume a nullability policy - null, empty, or both is an external decision, not an internal one, so this information needs to be preserved. static (modifier) - is both reserver word and a contract that allows null handling (full explanation later).
  • must not assume external traversal (view) logic - an empty range contains no elements, so there is nothing to sort and nothing out of bounds to touch. Such a range can come from a loop stepping by -3 that ends at from = to = -2, or from an index that is valid in the original array but falls outside the part we were given.
    For clarification: Not empty ranges are still validated. Callers who treat an out-of-bounds empty range as a bug can state it explicitly: sort(ensure.indexRange(array, from, to), from, to).
  • must not assume a comparator can always be provided - a Comparator may be derived from the data itself, which is impossible for a null or empty array. Another example is deriving it from the differences between elements, which needs at least 2 elements. Forcing the caller to pass a placeholder (e.g. (x, y) -> 0) instead risks polluting the data domain, because that fake comparator can be stored, passed on and reused as if it were real. Sorting fewer than 2 elements needs no comparison, so a null comparator has exactly same ussage as real one.
    For clarification: For 2 or more elements it still fails, with no fallback to natural order. Requiring one need to be stated explicitly : sort(array, from, to, Objects.requireNonNull(c)).
Behind every decision there is a reason:
  • copying is safer,
  • mutating is faster,
  • null-friendly is more practical,
  • null-rejecting is cleaner,
  • fallback is convenient,
  • strict failure is more correct.
one or another justification takes the lead - and that's the exact reason why many bad APIs survive criticism.
Refraining from taking on others' responsibilities or assumptions.
Most glaring examples would be:
  • Preparing data for output:
    • generating XML manually
    • trimming strings
  • Bypassing the API layer and effectively welding the modules together.
  • Injecting garbage into the project by importing libraries without:
    • taking into account overall quality
    • checking dependencies
    • considering writing this 'one' method manually
  • Polluting memory and forcing other layers to clean it up.
  • Using given elements as operation cache.
Composability
Instead of writing code for a specific case, we can achieve much more by writing composable code - even if it requires more code upfront - using the same concepts on which collections were built.
Requirements can be added by composition:
  • Basic sort, no preconditions:
    array = sort(array, from, to);
  • Require array to be non-null:
    array = sort(Objects.requireNonNull(array), from, to);
  • Validate index range:
    array = sort(ensureIndexRange(array, from, to), from, to);
  • Require comparator to be non-null:
    array = sort(array, from, to, Objects.requireNonNull(comparator));
Using default Java utilities often forces structural changes:
  • Default:
    java.util.Arrays.sort(array, from, to);
  • With null support:
    if (array != null) { java.util.Arrays.sort(array, from, to); }
  • With range guard - multiple solutions exist:
    // Skip if empty if (array != null && from != to) { java.util.Arrays.sort(array, from, to); }
    // Reset if empty if (array != null) { if (from == to) { from = to = array.length; } java.util.Arrays.sort(array, from, to); }
The cost is not just code volume. Each requirement forces a workaround, and each workaround can be expressed in multiple equivalent ways. Readers must now understand not just what the code does, but also why this particular workaround was chosen over alternatives. As requirements multiply, the decision space grows, and the cognitive load of reading and understanding the code becomes unbounded. It may not look like much, but if we follow this style consistently, we end up with complex logic that requires workarounds. Additionally, whenever we introduce an exception (for example, null cannot be passed), we lose uniform logic, because from that point every call site must be validated against Javadoc details.
The contract cannot be abandoned.
Any additional changes to the contract should also be reflected in the name or space. Composition allows us to minimize name length, but that is not enough. Each algorithm exists within actual constraints, and determining the relation to them is not trivial. At the base, we have interface, memory, time, determinism, interactions, influence, sub-spaces, and many more. To make things easier, we can create groups that correlate with the domain.
To make that happen, lowercase static classes seem to be the best way to express a space for CSRD requirements.
  • They look and behave like packages, but we have more freedom in using them.
  • They are easy to filter out from imports, since importing them would violate CSRD.
public class Arrays { public static class mutable { public static class sorted { public static <C extends Comparable<? super C>> C[] distinct(C... array); } public static <T> T[] shuffle(T[] array); } public static class sorted { public static <C extends Comparable<? super C>> C[] distinct(C... array); } public static <T> T[] shuffle(T[] array); public static <T> T[] shuffleDerangement(T[] array); }
When refraining from importing lower-case classes, we will have a variety of ways to connect multiple domains:
  • mutable - the main argument and consumable ones can change state - including, but not limited to, changes after returning them.
  • sorted - we need to execute the algorithm according to the given order - {"a", "B", "a"} will give a different result than {"a", "a", "B"}.
  • ensure - pass through and report errors.
  • elements - limits the domain to elements in arrays/collections/...
  • onWords - shrinking would not cut words.
  • static (modifier) - a domain carried by the keyword itself: a static method can be called without an object, so it can receive null where an instance method could not. To not waste that, the method accepts null and returns what its domain defines for it: null when there is nothing to produce (sort(null)), or a real answer when the domain has one (e.g. equals(null, null) → true). That answer must follow from the domain itself, not from a chosen default: treating null as empty is a policy, not an answer. Like other defaults (e.g. immutability), it can be overridden by an explicit domain, such as Objects.requireNonNull, whose responsibility is rejection.
  • Arrays.shuffle(array); - will make a defensive copy of the array, shuffle it, and return it.
  • Arrays.mutable.shuffle(array); - will shuffle the given array and return it.
  • Arrays.sorted.distinct(array); - will return unique elements by comparing neighbours.
  • Arrays.mutable.sorted.distinct(array); - will return unique elements by comparing neighbours, but you can expect to get back the given array if possible as well.
  • shuffle - elements will be shuffled.
  • shuffleDerangement - no element stays in its original position, which is possible for size > 1.
Minimal expression.
  • If a problematic condition can be expressed through composition, the implementation should adopt the widest behaviour. For example, T randomElement(T[] array) is not required to throw when array.length == 1 (selecting from it is not really random), nor when the array is empty - it returns null, because there is nothing to produce. Stricter contracts are added through composition: randomElement(Arrays.ensure.minLength(array, 2)). If the array may contain null elements, Arrays.ensure.minLength(array, 1) also removes the ambiguity between "empty" and "picked null".
  • Domains that can be directly derived from the API do not need to be materialized as separate spaces. Sorting part of an array does not need partial space, as from and to already express the range. Immutability - which is the default for not destroying data, does not need to be stated.
Doing no harm vs providing help
We have a plethora of ways to write code and solve problems. Code should be written so as not to destroy other domains' effort / as long as it does not break the contract:
  • Empty arrays should always be reused, as they are immutable by themselves, so we do not need to worry about memory pollution if a utility uses one object every time.
  • If possible, we should limit the number of executed methods to a minimum, because we cannot predict the final cost.
  • Do not retain caller data longer than needed.
  • Do not silently replace caller intent with (internal) defaults or fallback behavior.
Don't make things up!
Good contracts are hard to establish, so partial or temporary contracts should be clearly marked until a full contract is in place.
Limitation lifting
When a domain lifts a limitation, the lifting must be carried by its own implementation, so callers never need to branch(route) around it. For example, a static method that accepts null handles it inside, instead of every caller adding if (x != null). Pushing the limitation up would repeat the same guard in every layer above, and the domain would claim a lift it does not deliver.
Domain hierarchy has to end somewhere
All points up till now show how CSRD forces us to write complex code to materialize simple operations so we can use them as universal building bricks. This can lead to the misconception that domains always force us to design and write each method safeguarding them against other domains and rare use cases. But there need to be understanding that domains stack - so we will need different requirements depending on layer we will want to use it on.
  • Universal type - domains expected to be used everywhere; they require the strictest and most universal rules.
  • Limited type - domains for projects focused on a single problem (e.g. a demo or a single project); they may allow weaker assumptions between methods, as long as they are not exposed as a public API.
  • Manual type - domains for supervised processes; they may omit some automated error handling, because the process itself provides oversight.
Changing a condition vs changing structure.
Optional is widely used in fields and parameters (it was intended for return values only) - it expresses the nullability policy through structure: a wrapper type that every signature, generic, and call chain must carry. When the policy changes, for example when a required value becomes optional (or the reverse):
  • Optional - the structure changes: fields, getters, constructors, mappers, generics, and tests that use Optional must all be rewritten.
  • CSRD - a condition changes: composed guards (Objects.requireNonNull, ensure) are added or removed at call sites.
Keeping the shape of the data stable across layers is a significant gain - the same reason -1 as "not found" and language-level nullability (T?) remain popular and widely used: the policy can change while the data shape stays the same. Type-level approaches catch errors earlier; composed guards are cheaper to change; each scope chooses exactly the strictness it needs.
// CSRD: array required result = sort(Objects.requireNonNull(array), from, to);
// CSRD: policy change = remove array null guard: result = sort(array, from, to);
// classic T[] field; java.util.Arrays.sort(field, from, to);
// Optional: policy change = structure change, spreading to every signature Optional<T[]> field; T[] result = field.map(a -> { java.util.Arrays.sort(a, from, to); return a; }).orElse(null); // + every getter, constructor, mapper, test...
Real life example - analysis.
java.util.Objects: public static boolean equals(Object a, Object b) { return (a == b) || (a != null && a.equals(b)); }
Responsibility table:
a b Who decides  NPE for a.equals(null)   NPE for b.equals(null) 
null null Objects decides: true irrelevant irrelevant
null non-null Objects decides: false irrelevant not called
non-null null handed to a.equals(null) thrown irrelevant
  non-null     non-null     handed to a.equals(b)   irrelevant irrelevant
This code neither cut-off null nor passes it to Object.equals(null) consistently, and the result depends on argument order, not on the values.
Implementation with proper contract would be one of:
  • take over null check:
    if (a == b) return true; if (a == null || b == null) return false; return a.equals(b);
  • always delegate it:
    if (a == b) return true; // (null, null): no object to ask, so identity is the only answer return a != null ? a.equals(b) : b.equals(null);
Either version is symmetric: the first fully owns the null policy, the second fully delegates it.
Throwing from equals - on null or on foreign types - violates the Object.equals contract, but in a limited domain it can serve as a strict guard that prevents security-sensitive objects from being silently compared with, or mixed into, other object spaces, as long as every framework in use tolerates it and the class is clearly marked, directly or by its domain (Such a class must never reach a universal domain).

0 comments / komentarz(y):

Post a Comment